1. agent loop 中间内容怎么来的?
在 666ec652 这类 OpenAI 链里,中间内容来自同一轮模型响应同时包含
assistant.content 和 tool_calls。Aether 上游看到的是 Responses 的
output[type=message] + output[type=function_call],再转换回 Chat 的
choices[0].message.content + choices[0].message.tool_calls。
因此 UI 能不能显示“正在创建目录”“继续查日志”这类中间步骤,第一取决于模型有没有输出文本,
第二取决于客户端有没有保留并渲染这段文本。
2. 为什么有的又不行?
6a835bee 是同样的 OpenAI 工具调用,但响应里 content:null,只有 tool_calls。
这时客户端没有 assistant 文本可显示,只能显示“工具调用结构”或干脆不显示中间文字。
Gemini Generate Content 的情况更不同:5928d8ae 返回的是 functionCall part,
没有 text part;最终文字在下一轮 c82cd63a 回填 functionResponse 后才出现。
3. 这些东西需要软件端支持吗?
需要。软件端要发送工具 schema、解析模型返回的 tool call、执行工具、把结果按协议回填、
再把 assistant commentary、functionCall、functionResponse 这些中间结构渲染给用户。
Aether 不是 agent loop 本身,但它记录了 loop 的每一层:客户端请求、上游请求、上游响应、
客户端响应和压缩 body blob,所以可以用来复原客户端如何显示中间步骤。
4. steer 如何构建并生效?
steer 在这批 OpenAI 请求里不是一个独立字段,而是展开到 system/instructions、
当前用户任务、动态上下文、历史工具结果、工具 schema、tool_choice、reasoning_effort
等标准字段里。
54305c44 → 84af699b 说明了它如何生效:上一轮证据把问题从“权限/前缀”收窄为
“首条 yield 后流程没继续”,下一轮就自然改查 stop_event + yield + pipeline。
OpenAI Chat
message.content + tool_calls
客户端兼容形态;arguments 是 JSON 字符串。
OpenAI Responses
output[]: message + function_call
Aether 上游实际形态;可带 commentary phase。
Gemini Generate Content
parts[]: functionCall / text
观察样本里 functionCall-only 与 text-only 分轮出现。
Gemini Interactions
steps: model_output / function_call
官方面向 agentic workflow;Aether 当前无近 14 天调用记录。
证据索引
本页使用的关键请求、字段和结论
下面是从 Aether 数据库和 live 代码中抽出的“决定性证据”。大体量原始 body 没有整篇塞进页面,
因为它们是 gzip 压缩 JSON 且动辄数万 token;页面展示的是足以复核结论的字段路径、摘录和复现查询。
过滤证据
666ec652
2026-07-06 00:42:26
OpenAI Chat → Responses
同一轮同时有 assistant content 和工具调用
client_response_body.choices[0].message.content 长度 68,同时
tool_calls[0].function.name = astrbot_execute_shell,finish_reason = tool_calls。
复制 {
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": "确认到插件常见结构啦。现在创建到 /AstrBot/data/plugins 下...",
"tool_calls": [
{
"id": "call_d22h4bpSmyiw5g8ac8Sit36b",
"type": "function",
"function": {
"name": "astrbot_execute_shell",
"arguments": "{\"command\":\"mkdir -p ...\"}"
}
}
]
}
}
6a835bee
2026-07-06 00:42:21
OpenAI Chat → Responses
只有工具调用,没有中间文字
同样是工具调用轮次,但 client_response_body.choices[0].message.content = null,
因而 UI 没有 assistant 文本可显示。
复制 {
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"function": {
"name": "astrbot_grep_tool",
"arguments": "{\"pattern\":\"@filter.command\",...}"
}
}
]
}
}
5928d8ae
2026-07-05 19:31:24
Gemini Generate Content
Gemini 第一轮只返回 functionCall
response_body.candidates[0].content.parts[0].functionCall 存在;同一 part 没有 text。
这就是 Gemini 中间步骤不是 assistant content 的直接证据。
复制 {
"finishReason": "STOP",
"parts": [
{
"functionCall": {
"name": "ltm_memory_search",
"args": { "query": "2026-05-20 虹云彼方 蓝狗" },
"id": "01p5ltji"
}
}
]
}
c82cd63a
2026-07-05 19:31:53
Gemini Generate Content
下一轮回填 functionResponse 后产生最终文本
请求体 contents[2] 是上一轮 role=model functionCall,contents[3] 是
role=user functionResponse。响应才出现最终 text。
复制 {
"contents": [
"...",
{
"role": "model",
"parts": [{ "functionCall": { "name": "ltm_memory_search", "id": "01p5ltji" }}]
},
{
"role": "user",
"parts": [{ "functionResponse": { "name": "ltm_memory_search", "response": { "data": "..." }}}]
}
]
}
tools=30
666ec652 / 1ba6949c
OpenAI function tools
OpenAI 链每轮带完整 30 个工具
request_body.tools.length = 30。实际本任务常用 astrbot_execute_shell、
astrbot_execute_python、astrbot_file_read_tool、astrbot_file_write_tool、
astrbot_grep_tool。
Gemini tools
5928d8ae / c82cd63a / bf080d07
functionDeclarations
Gemini 单工具链与 8 工具 LTM 链
5928d8ae 和 c82cd63a 的 functionDeclarations 只有 ltm_memory_search;
同窗口 bf080d07 有 8 个 LTM 工具。
54305c44 → 84af699b
2026-07-09 05:00
steer chain
上一轮判断改变下一轮检索方向
54305c44 从“权限/前缀”转向“首条 yield 后流程没继续”;
84af699b 随后检索 stop_event + yield + pipeline。这是 steer 在任务链中生效的现场证据。
Aether 字段
为什么 Aether 能帮我们复原客户端中间步骤
Aether 的关键价值不是“它实现了 agent loop”,而是它把一次请求的几层身体都记录下来了:
客户端原始请求、Aether 转给上游的请求、上游真实响应、Aether 回给客户端的响应。
OpenAI 这批请求尤其重要,因为客户端进来是 openai:chat,而 provider 侧被转换成
openai:responses。如果只看其中一个 body,就会误判“中间 content”到底来自哪里。
request_body 客户端送进 Aether 的 Chat/Gemini 原始格式
provider_request_body Aether 转给上游 provider 的真实格式
response_body provider 返回给 Aether 的原始响应
client_response_body Aether 转回客户端看到的兼容响应
复制 SELECT request_id, body_field, octet_length(payload_gzip)
FROM usage_body_blobs
WHERE request_id IN (
'666ec652-163f-4277-a6fe-1d64a81abf03',
'5928d8ae-4cf8-43ad-be59-c111b48b66fe',
'c82cd63a-692c-48c5-b09b-7633ceacd83a',
'54305c44-a5a6-4123-a415-755bfa175e5d',
'84af699b-58ec-4832-9d68-6c9dd5eac975'
)
ORDER BY request_id, body_field;
中间 assistant content
OpenAI:为什么一轮里可以既说话又调用工具
1
客户端请求是 Chat
messages + tools + tool_choice:auto 进入 Aether。
2
Aether 转成 Responses
messages[0] 变成 instructions,历史消息和工具结果变成 input[]。
3
上游返回两个输出
output[type=message] 是中间文字,output[type=function_call] 是工具调用。
4
Aether 转回 Chat
客户端看到 message.content + message.tool_calls,finish_reason=tool_calls。
666ec652:有中间文字
复制 response_body.output = [
{ "type": "reasoning", "encrypted_content": "..." },
{
"type": "message",
"role": "assistant",
"phase": "commentary",
"content": [
{ "type": "output_text", "text": "确认到插件常见结构啦。现在创建到..." }
]
},
{
"type": "function_call",
"name": "astrbot_execute_shell",
"call_id": "call_d22h4bpSmyiw5g8ac8Sit36b",
"arguments": "{\"command\":\"mkdir -p ...\"}"
}
]
6a835bee:无中间文字
复制 client_response_body.choices[0] = {
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"function": {
"name": "astrbot_grep_tool",
"arguments": "{\"pattern\":\"@filter.command\",...}"
}
}
]
},
"finish_reason": "tool_calls"
}
Gemini
Generate Content、functionCall 与 Interactions 的差异
对 Gemini 的结论要分清“API 能不能工具调用”和“中间 assistant content 能不能混在同一轮里显示”。
Generate Content 支持 function calling;但在 Aether 记录的样本里,我们观察到的是
functionCall-only 和 text-only 分轮出现,没有发现 text 与 functionCall 混合在同一个候选响应里。
之前统计过近 14 天 Gemini Generate Content 响应体:text_only = 1522、
function_call_only = 769、neither = 125、text + functionCall mixed = 0。
这不是说协议绝对不可能表达混合,而是说当前 Aether 记录中的实际行为没有出现混合。
19:31:24
5928d8ae 调用工具
提示词明确要求最终回复前必须调用 ltm_memory_search,响应只返回 functionCall。
客户端执行
工具返回图片记忆与消息检索结果
结果包含 image_memory、messages、ufs 等分支状态。
19:31:53
c82cd63a 回填并最终回复
contents[2] 是 model 的 functionCall,contents[3] 是 user 的 functionResponse。
Gemini /v1/interactions 的位置
官方 Interactions API 面向 agentic workflow,响应结构有 steps,能表达 model_output、
function_call 等步骤。Aether live 代码里存在 gemini:interactions、路径 /v1/interactions
的识别与同格式转发支持,但最近 14 天 Aether 请求记录里 gemini:interactions = 0,
因此这里不能拿现场调用证明它在当前客户端 UI 中已经实际落地。
crates/aether-admin/src/system.rs:808 注册 gemini:interactions。
apps/aether-gateway/src/control/route/ai.rs:100 识别 POST /v1/interactions。
crates/aether-provider-transport/src/request_url/mod.rs:153 构造 /v1/interactions。
crates/aether-ai-formats/src/formats/matrix.rs:598 显示为同格式路径,不是 Generate Content 互转。
完整链路
tool call 从提示词教学到工具结果回填
提示词教学 用户任务、system、attention、强制工具提醒
工具自带教学 name、description、parameters、required、enum
模型结构化输出 tool_calls 或 functionCall
客户端执行 shell、grep、read、memory search
结果回填 role=tool 或 functionResponse
下一轮推理 基于新证据继续收窄
提示词教学负责“为什么要调用工具”;工具 schema 负责“能调用什么、参数怎样合法”;
tool_choice:auto 负责“允许模型自行选择”;客户端执行器负责“真正执行工具”;
历史回填负责“下一轮模型知道刚才发生了什么”。
只要其中任一层缺失,agent loop 就会断:没有 schema,模型不知道可用工具;没有执行器,
tool call 只是 JSON;没有回填,下一轮模型不知道工具结果;没有 UI 渲染,用户看不到中间步骤。
OpenAI 回填
复制 {
"role": "assistant",
"content": "确认到插件常见结构啦...",
"tool_calls": [
{
"id": "call_d22h4bpSmyiw5g8ac8Sit36b",
"function": { "name": "astrbot_execute_shell", "arguments": "{\"command\":\"...\"}" }
}
]
}
{
"role": "tool",
"tool_call_id": "call_d22h4bpSmyiw5g8ac8Sit36b",
"content": "{\"stdout\":\"...\",\"stderr\":\"\",\"exit_code\":0}"
}
Gemini 回填
复制 {
"role": "model",
"parts": [
{
"functionCall": {
"name": "ltm_memory_search",
"args": { "query": "2026-05-20 虹云彼方 蓝狗" },
"id": "01p5ltji"
}
}
]
}
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": "ltm_memory_search",
"response": { "data": "..." }
}
}
]
}
steer
54305c44 → 84af699b:steer 如何在 LLM 调用中生效
这里的 steer 不是请求体里的单个 steer 字段,而是“下一次模型调用前,哪些证据和约束被放进上下文,
从而改变模型下一步判断和工具选择”。更具体地说:messages[] 里的历史 assistant 判断、
role=tool 工具结果、tools=30 的工具菜单、tool_choice:auto 的选择权限、
以及 provider 侧 instructions + input[] + reasoning.effort=xhigh 一起形成了模型的行动斜坡。
所以判断 steer 是否生效,不能只问“有没有 steer 字段”;要看下一轮调用的输入里是否带着上一轮的结论和工具结果,
以及下一轮输出的工具调用是否确实沿着这些证据改了方向。
1. 用户目标 steer
起点不是泛泛排查,而是用户明确给了两个命令现象:输入 /all_image_siglip_tagging 后打标工作没有开始,
/img_resemantic 同理,并询问“哪里有错误,或者任务是否被中止”。这把搜索空间限制在
AstrBot 指令触发、插件处理器、任务启动与中止链路。
2. 早期假设 steer
在 54305c44 之前,模型已经查过 PermissionType.ADMIN、admins_id、wake_prefix、
plugin_set、命令覆盖配置等。此时上下文里的有效假设仍偏向“命令是否没进入处理器”。
3. 排除证据 steer
工具结果显示 wake_prefix ['/'] 实际可用、plugin_set ['*'] 启用、全局 admins_id
含用户 ID,群工作区也有基础管理员配置;这会削弱“前缀错了/插件被禁用”的解释。
4. 关键转向 steer
54305c44 输出了新的中间判断:“那就不是权限没进方法;按 bot 立刻响应看,问题更像是首条
yield 后流程没继续,或处理首张时卡死/异常。”这句话不是装饰性文本,它会进入下一轮请求历史,
成为后续模型继续推理的显式证据。
54305c44 调用前:模型看到的证据尾部
请求形态
openai:chat 转 openai responses;messageCount=32,provider.input=47。
工具菜单
tools=30,其中可选 astrbot_execute_python、astrbot_grep_tool、astrbot_file_read_tool 等。
刚拿到的工具结果
wake_prefix ['/']、plugin_set ['*']、admins_id ['astrbot','389724163'],以及命令覆盖存储检查。
参数环境
tool_choice:auto 允许模型继续自主选工具;reasoning.effort=xhigh 允许较长链路推理。
54305c44 输出:把下一步推向 pipeline
这一轮没有继续查权限,而是改用 astrbot_grep_tool 搜 async for.*handler、
async_generator、__anext__、MessageEventResult、activated_handlers,
搜索路径也从配置区转到 /AstrBot/astrbot/core/pipeline。
这就是 steer 的第一段可见结果:同样是 tool_choice:auto,但历史证据改变了“自动选择”的落点。
84af699b 调用前:上一轮被如何写回
历史 assistant message
input[47] 带入 54305c44 的判断:“不是权限没进方法,更像首条 yield 后流程没继续”。
历史 function_call
input[48] 带入 call_G1i6x...,工具名是 astrbot_grep_tool,pattern 指向 handler/yield 相关代码。
历史 function_call_output
input[49] 带入 grep 结果,命中 waking_check/stage.py、activated_handlers、MessageEventResult、event.stop_event()。
84af699b 输出:进一步收窄到 stop_event + yield
第二轮输出明确说:“命令方法进来了,而且第一条 yield 发出;我重点查 stop_event + yield 后管线是否不再恢复。”
随后的工具调用不再查权限和配置,而是集中到 is_stopped()、stop_event()、
async for stage、stage.process、STAGES_ORDER、
RespondStage/yield 这些 pipeline 恢复点。
这不是抽象地“受上下文影响”,而是上一轮的判断和 grep 结果,直接改变了下一轮搜索关键词、搜索路径和故障模型。
04:58 前
主假设偏向权限、管理员、命令前缀、插件启用状态。
04:59:14
确认 wake_prefix ['/'],命令前缀不是主因。
05:00:25
54305c44 将假设切到“方法已经进了,但 yield 后流程没继续”。
05:00:41
84af699b 把上一轮 grep 输出作为输入,进一步查 stop_event + yield + pipeline。
机制结论
steer 的生效点在“下一轮请求构造”:上一轮 assistant 内容、function_call、function_call_output 被写回
messages[] / provider_request_body.input[],模型再基于这些证据选择新的工具参数。
54305c44 的输出:第一次转向
复制 {
"request_shape": {
"api_format": "openai:chat",
"provider_endpoint_kind": "responses",
"message_count": 32,
"provider_input_count": 47,
"tools": 30,
"tool_choice": "auto",
"reasoning": { "effort": "xhigh" }
},
"assistant_content": "那就不是权限没进方法;按“bot 立刻响应”看,问题更像是首条 yield 后流程没继续,或处理首张时卡死/异常。",
"tool_call": {
"name": "astrbot_grep_tool",
"arguments": {
"pattern": "async for.*handler|inspect.isasyncgen|async_generator|__anext__|MessageEventResult|handler\\(|call_handler|activated_handlers",
"path": "/AstrBot/astrbot/core/pipeline",
"result_limit": 200
}
}
}
84af699b 的输入与输出:第二次收窄
复制 {
"input_tail": [
{ "type": "message", "content": "不是权限没进方法;更像首条 yield 后流程没继续..." },
{ "type": "function_call", "name": "astrbot_grep_tool", "call_id": "call_G1i6x..." },
{ "type": "function_call_output", "call_id": "call_G1i6x...", "output_contains": [
"waking_check/stage.py",
"activated_handlers",
"MessageEventResult",
"event.stop_event()"
]}
],
"assistant_content": "命令方法进来了,而且第一条 yield 发出;我重点查 stop_event + yield 后管线是否不再恢复。",
"tool_call": {
"name": "astrbot_grep_tool",
"arguments": {
"pattern": "is_stopped\\(|stop_event\\(|async for.*stage|stage.process|pipeline|STAGES_ORDER|RespondStage|yield",
"path": "/AstrBot/astrbot/core/pipeline",
"result_limit": 240
}
}
}
把这件事迁移到任意客户端实现
如果你要自己构建 steer,不要发明一个神秘字段;先保证 loop 状态被正确回填。对 OpenAI Chat 形态来说,
assistant 的 content、tool_calls、对应 role=tool 的结果必须一起进入下一轮 messages[]。
对 Responses 形态来说,等价结构是 message、function_call、function_call_output 进入 input[]。
只有这样,模型才能把“刚才查到的东西”变成“下一步该查什么”的约束。
OpenAI Chat 工具声明
复制 request_body.tools[i].function.name
request_body.tools[i].function.description
request_body.tools[i].function.parameters.properties
request_body.tools[i].function.parameters.required
OpenAI Chat 工具调用
复制 client_response_body.choices[0].message.tool_calls[j].id
client_response_body.choices[0].message.tool_calls[j].function.name
client_response_body.choices[0].message.tool_calls[j].function.arguments
// 注意:arguments 是 JSON 字符串,必须二次 JSON.parse。
OpenAI Responses 工具调用
复制 response_body.output[k].type == "function_call"
response_body.output[k].name
response_body.output[k].call_id
response_body.output[k].arguments
Gemini 工具调用
复制 response_body.candidates[0].content.parts[j].functionCall.name
response_body.candidates[0].content.parts[j].functionCall.args
response_body.candidates[0].content.parts[j].functionCall.id
// 注意:args 通常已经是对象。
复制 const openAiArgs = JSON.parse(
clientResponse.choices[0].message.tool_calls[0].function.arguments
);
const geminiArgs =
geminiResponse.candidates[0].content.parts[0].functionCall.args;
console.log(openAiArgs.command || openAiArgs.pattern);
console.log(geminiArgs.query);
原始请求体保存在 usage_body_blobs.payload_gzip,外键是 usage.request_id。
复查时不要直接把 gzip 十六进制整段打印到终端;应该解压后只抽关键路径。
复制 docker exec aether-postgres psql -U postgres -d aether -t -A -F $'\t' -c "
SELECT request_id, body_field, encode(payload_gzip,'hex')
FROM usage_body_blobs
WHERE request_id IN (
'666ec652-163f-4277-a6fe-1d64a81abf03',
'5928d8ae-4cf8-43ad-be59-c111b48b66fe',
'c82cd63a-692c-48c5-b09b-7633ceacd83a',
'54305c44-a5a6-4123-a415-755bfa175e5d',
'84af699b-58ec-4832-9d68-6c9dd5eac975'
)
ORDER BY request_id, body_field;
"