Aether evidence room

Aether 请求证据室 tool call、agent loop 与 steer 的完整链路

这份 HTML 把前面对 Aether 记录的调查合成一张可复核的认知地图:从 OpenAI 的中间 assistant content,到 Gemini 的 functionCall/functionResponse,再到工具 schema、JSON 解析、 agent loop 回填,以及 54305c44 → 84af699b 任务链里 steer 如何真正改变模型下一步。

LLM 工具调用链路示意 提示词与 steer system · context · tools 模型输出 content + tool call 客户端执行器 shell · grep · read 工具结果回填 role=tool / functionResponse 下一轮 steer 收窄问题空间
核心不是“模型执行工具”,而是模型提出结构化调用,客户端执行,再把结果写回下一轮。

把前面所有问题压成一张总图

1. agent loop 中间内容怎么来的?

666ec652 这类 OpenAI 链里,中间内容来自同一轮模型响应同时包含 assistant.contenttool_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_choicereasoning_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_shellfinish_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 functionCallcontents[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_shellastrbot_execute_pythonastrbot_file_read_toolastrbot_file_write_toolastrbot_grep_tool

Gemini tools 5928d8ae / c82cd63a / bf080d07 functionDeclarations

Gemini 单工具链与 8 工具 LTM 链

5928d8aec82cd63afunctionDeclarations 只有 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 的关键价值不是“它实现了 agent loop”,而是它把一次请求的几层身体都记录下来了: 客户端原始请求、Aether 转给上游的请求、上游真实响应、Aether 回给客户端的响应。

OpenAI 这批请求尤其重要,因为客户端进来是 openai:chat,而 provider 侧被转换成 openai:responses。如果只看其中一个 body,就会误判“中间 content”到底来自哪里。

request_body客户端送进 Aether 的 Chat/Gemini 原始格式
provider_request_bodyAether 转给上游 provider 的真实格式
response_bodyprovider 返回给 Aether 的原始响应
client_response_bodyAether 转回客户端看到的兼容响应
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;

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_callsfinish_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"
}

Generate Content、functionCall 与 Interactions 的差异

对 Gemini 的结论要分清“API 能不能工具调用”和“中间 assistant content 能不能混在同一轮里显示”。 Generate Content 支持 function calling;但在 Aether 记录的样本里,我们观察到的是 functionCall-onlytext-only 分轮出现,没有发现 text 与 functionCall 混合在同一个候选响应里。

之前统计过近 14 天 Gemini Generate Content 响应体:text_only = 1522function_call_only = 769neither = 125text + functionCall mixed = 0。 这不是说协议绝对不可能表达混合,而是说当前 Aether 记录中的实际行为没有出现混合。

5928d8ae 调用工具

提示词明确要求最终回复前必须调用 ltm_memory_search,响应只返回 functionCall

工具返回图片记忆与消息检索结果

结果包含 image_memorymessagesufs 等分支状态。

c82cd63a 回填并最终回复

contents[2] 是 model 的 functionCall,contents[3] 是 user 的 functionResponse。

Gemini /v1/interactions 的位置

官方 Interactions API 面向 agentic workflow,响应结构有 steps,能表达 model_outputfunction_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 互转。

这些请求里到底带了多少工具、有哪些工具

666ec6521ba6949crequest_body.tools.length = 30。每个工具形态是 {"type":"function","function":{name,description,parameters}}

save_knowledge保存知识图谱节点与关系;必填 nodes
save_memory保存长期记忆;必填 content
search_memory检索长期记忆;必填 query
correct_memory修正错误记忆;必填 memory_id/correction/reason
search_knowledge_graph搜索实体和关系;必填 query
get_profile获取用户或群聊画像;可传 target_type/target_id
recall_long_term_memoryLivingMemory 搜索别名;必填 query
memorize_long_term_memoryLivingMemory 写入别名;必填 content
wave_memory_search搜索历史记忆和对话;必填 query
wave_memory_remember主动记住信息;必填 content
wave_memory_deep_search关键词深度搜索对话上下文;必填 keywords
wave_memory_facts搜索事实三元组;必填 query
wave_memory_tag_graph查询标签关系网络;必填 tag_name
wave_memory_explain_injection解释一次注入 trace;必填 trace_id
wave_memory_feedback_memory对命中记忆反馈;必填 trace_id/memory_id/feedback
wave_memory_suggest_config提交配置优化建议;必填 scope/problem/evidence_trace_ids
wave_memory_submit_review_candidate提交审查候选;必填 type/content/evidence/reason
wave_memory_person_search按人物搜索记忆;必填 person
wave_memory_affinity查询互动排行、活跃用户或个人互动。
book_lore_search搜索书设知识库;必填 query
book_lore_graph查看书设实体关系;必填 entity_name
astr_kb_search查询知识库;必填 query
astrbot_execute_shell执行 shell;必填 command,可选 timeout/background/env
astrbot_execute_python执行 Python;必填 code,可选 silent/timeout
astrbot_file_read_tool读取文件;必填 path,可选 offset/limit
astrbot_file_write_tool写 UTF-8 文件;必填 path/content
astrbot_file_edit_tool替换文件内容;必填 path/old/new
astrbot_grep_toolripgrep 搜索;必填 pattern,可选 path/glob/-A/-B/-C/result_limit
future_task管理未来任务;必填 action
send_message_to_user主动发送消息;必填 messages

5928d8aec82cd63a 的工具容器数量是 1,里面只有一个 functionDeclarationltm_memory_search

{
  "name": "ltm_memory_search",
  "description": "Search memory as one read-only ability. Use this when the user asks to search memory, global memory, occurrence counts, previous mentions, media/image memory, or evidence...",
  "parameters": {
    "type": "object",
    "properties": {
      "query": { "type": "string" },
      "scope": {
        "type": "string",
        "enum": ["current_conversation", "global"]
      },
      "limit": { "type": "integer" }
    },
    "required": ["query"]
  }
}

bf080d07 是同窗口 Gemini LTM agent 的完整工具组:外层 tools=1, 但 functionDeclarations=8

ltm_msg_query查询本地消息,可本地优先或远端回退。
ltm_memory_recall_core召回核心记忆;必填 query
ltm_memory_expand展开记忆详情;必填 memory_id
ltm_memory_search统一只读记忆搜索;必填 query
ltm_msg_refresh强制远端消息拉取并写入本地。
ltm_msg_send_text发送纯文本消息;必填 message
ltm_msg_wait等待匹配消息出现。
ltm_memory_write_core写核心记忆;必填 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": "..." }
      }
    }
  ]
}

54305c44 → 84af699b:steer 如何在 LLM 调用中生效

这里的 steer 不是请求体里的单个 steer 字段,而是“下一次模型调用前,哪些证据和约束被放进上下文, 从而改变模型下一步判断和工具选择”。更具体地说:messages[] 里的历史 assistant 判断、 role=tool 工具结果、tools=30 的工具菜单、tool_choice:auto 的选择权限、 以及 provider 侧 instructions + input[] + reasoning.effort=xhigh 一起形成了模型的行动斜坡。

所以判断 steer 是否生效,不能只问“有没有 steer 字段”;要看下一轮调用的输入里是否带着上一轮的结论和工具结果, 以及下一轮输出的工具调用是否确实沿着这些证据改了方向。

现场案例 从“命令没进/权限前缀”到“首条 yield 后 pipeline 停住”

1. 用户目标 steer

起点不是泛泛排查,而是用户明确给了两个命令现象:输入 /all_image_siglip_tagging 后打标工作没有开始, /img_resemantic 同理,并询问“哪里有错误,或者任务是否被中止”。这把搜索空间限制在 AstrBot 指令触发、插件处理器、任务启动与中止链路。

2. 早期假设 steer

54305c44 之前,模型已经查过 PermissionType.ADMINadmins_idwake_prefixplugin_set、命令覆盖配置等。此时上下文里的有效假设仍偏向“命令是否没进入处理器”。

3. 排除证据 steer

工具结果显示 wake_prefix ['/'] 实际可用、plugin_set ['*'] 启用、全局 admins_id 含用户 ID,群工作区也有基础管理员配置;这会削弱“前缀错了/插件被禁用”的解释。

4. 关键转向 steer

54305c44 输出了新的中间判断:“那就不是权限没进方法;按 bot 立刻响应看,问题更像是首条 yield 后流程没继续,或处理首张时卡死/异常。”这句话不是装饰性文本,它会进入下一轮请求历史, 成为后续模型继续推理的显式证据。

54305c44 调用前:模型看到的证据尾部

请求形态
openai:chatopenai responsesmessageCount=32provider.input=47
工具菜单
tools=30,其中可选 astrbot_execute_pythonastrbot_grep_toolastrbot_file_read_tool 等。
刚拿到的工具结果
wake_prefix ['/']plugin_set ['*']admins_id ['astrbot','389724163'],以及命令覆盖存储检查。
参数环境
tool_choice:auto 允许模型继续自主选工具;reasoning.effort=xhigh 允许较长链路推理。

54305c44 输出:把下一步推向 pipeline

这一轮没有继续查权限,而是改用 astrbot_grep_toolasync for.*handlerasync_generator__anext__MessageEventResultactivated_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.pyactivated_handlersMessageEventResultevent.stop_event()

84af699b 输出:进一步收窄到 stop_event + yield

第二轮输出明确说:“命令方法进来了,而且第一条 yield 发出;我重点查 stop_event + yield 后管线是否不再恢复。” 随后的工具调用不再查权限和配置,而是集中到 is_stopped()stop_event()async for stagestage.processSTAGES_ORDERRespondStage/yield 这些 pipeline 恢复点。

这不是抽象地“受上下文影响”,而是上一轮的判断和 grep 结果,直接改变了下一轮搜索关键词、搜索路径和故障模型。

主假设偏向权限、管理员、命令前缀、插件启用状态。

确认 wake_prefix ['/'],命令前缀不是主因。

54305c44 将假设切到“方法已经进了,但 yield 后流程没继续”。

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 的 contenttool_calls、对应 role=tool 的结果必须一起进入下一轮 messages[]。 对 Responses 形态来说,等价结构是 messagefunction_callfunction_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;
"