Agent 调用流程(权威设计)#
这是 agent 调用的核心框架——所有 turn / LLM 调用都按这个流程走。后续加功能在此基础上往对应节点插入,不改骨架。
总览图#
agent-call-flow.svg 画的是目标(统一后):两个入口(用户消息 / 函数体内)汇到同一条主干——① render_context 读上下文 → ② open_call_node → ③ agent_loop → ④ close_call_node;工具体内再调 exec = 回到主干顶(嵌套)。各入口的特殊处理(聊天的前端流式/标题/压缩、共享的重试/计量)是挂在主干旁的可选钩子。底部列出"现状已共享"与"合并要做的 7 步"。
现状 vs 目标:下面正文描述的是现状(两个入口各管各的外围、各写各的记录,只在 agent_loop 汇合)——作为合并的起点参照。统一成上面那条主干的具体步骤,见
dag/overview.md第八节。
骨架一句话(现状):两个平级入口(dispatcher · runtime.exec)各管各的外围、各写各的 DAG 记录,只在共享的 agent_loop 汇合;exec 在 loop 之下 → 工具体内可再调 LLM 形成嵌套。
真实拓扑:收敛分叉,不是三层嵌套#
代码实证(agent_loop 全仓只有两个调用点:dispatcher/__init__.py:902 和 agent/agent.py:418):dispatcher 不经过 exec,exec 不经过 dispatcher。它们是平级的两个入口,只共享 agent_loop。
入口 A: 用户消息 → dispatcher ──直接─────────────┐
├─→ agent_loop(共享引擎)
入口 B: @agentic_function 体内 → runtime.exec ──经 AgentSession─┘
| 入口 | 职责 | 到 agent_loop 的路径 | 写什么 DAG 记录 | 实现 |
|---|---|---|---|---|
| A · dispatcher | turn 生命周期:session、user 节点、attach Runtime、resolve model、解析工具、持久化、前端广播 | 直接 agent_loop(...)(__init__.py:902) |
顶层回复 → assistant 会话消息(SessionDB, persistence.py:207) |
agent/dispatcher/__init__.py |
| B · runtime.exec | 一次 LLM 调用:开/关 llm 节点、构建上下文 | 经 AgentSession → session.run(runtime.py:1576)→ agent.py:418 |
这次调用 → role=llm DAG 节点 | agentic_programming/runtime.py |
| 共享 · agent_loop | tool loop 引擎:调模型 → 执行工具 → 喂回 → 循环到纯文本 | — | 工具的 code 节点 called_by → 当前 llm 节点 | agent/agent_loop.py:114 |
为什么是两个入口,不是一个#
dispatcher 和 exec 服务两种不同场景,外围插件不重叠:dispatcher 要 session 管理 + 前端 WebSocket 广播 + 标题/压缩;exec 要 DAG llm 节点 + AgentSession retry-rollback。硬合并会让一方背上另一方不需要的逻辑;实际折叠时会撞到模型分叉和节点重复(见下文"为什么 dispatcher 不折叠进 runtime.exec")。共享 agent_loop,因为"调模型→执行工具→循环"这个引擎对两者一样。
关键:exec 在 agent_loop 之下,所以能嵌套#
当 dispatcher 跑 turn,模型调了 @agentic_function 工具(如 wiki_agent)→ 工具体内 runtime.exec → 又起一个 agent_loop。所以 exec 既是"入口 B",又被 agent_loop 里的工具反向调用:
dispatcher → agent_loop → 模型调工具 → @agentic_function 体 → runtime.exec → agent_loop(嵌套)→ ...
这是 agentic programming 的根本能力(函数体内可嵌套调 LLM),也是 wiki_agent 递归的来源。如果 exec 在 loop 之上,工具就无法自己再调 LLM。
每个节点内部的有序步骤#
A · dispatcher:1 建/载 session(沿 active branch 取历史)→ 2 写 user 节点 → 3 attach Runtime(_store + _current_runtime)→ 4 resolve model(agent profile + override)→ 5 解析工具(channel/plan/审批 包装)→ 6 直接调 agent_loop → 7 持久化 + finalize(assistant 节点、标题、auto-compact)。
B · runtime.exec:1 开 llm 节点(running,_call_id 指向它,外含 timeout/retry 循环)→ 2 构建上下文【a 选历史节点 render_context → b render 成消息 + 当前 turn → c 解析工具集 toolset/policy/unattended-deny → d 拼 system + skills → e 建 AgentSession(选 stream_fn)】→ 3 经 AgentSession 调 agent_loop → 4 关 llm 节点(回填 output,success)。
共享 · agent_loop:每轮调模型前【a convert_to_llm → b memory prefetch → c deferred-tool re-split】→ 调模型/流式 → 判断 tool_use? → 是:执行工具 → 结果喂回 → 再调模型;否(纯文本):退出循环。
横向对比#
| OpenCode | OpenClaw | Hermes | OpenProgram (统一后) | |
|---|---|---|---|---|
| 一次 LLM 调用的抽象 | Step(一个节点,tool loop 在内部) |
runId 配对(llm_input/llm_output,tool 嵌套在内) |
无层级,平铺列表 | exec(一个 llm 节点,tool loop 在内部) |
| 记录方式 | 配对:Step.Started → Step.Ended | 配对:llm_input hook → llm_output hook | 只追加,不标记 | 配对:写 running → 回填 output |
| 工具调用归属 | ToolPart 在 Step 内部(子部分) | 嵌套在同一个 runId 下(子 hook) | 平级 tool 消息 | code 节点的 called_by 指向 llm 节点(子节点) |
| 聊天 vs 编程调用 | 统一(一条路径) | 统一(同一个 hook 系统) | 统一(一个 run_conversation) | 统一(都走 runtime.exec) |
OpenProgram 采用 OpenCode/OpenClaw 的模型:一次调用 = 一个节点,配对写入,工具是子节点。
问题(现状)#
现在"调一次 LLM"有三条路径:
| 路径 | 入口 | tool loop | DAG 写 llm 节点 |
|---|---|---|---|
| 常规聊天 | dispatcher → agent_loop | agent_loop 管 | dispatcher 写 SessionDB 消息(不是 DAG llm 节点) |
| exec legacy | exec → self._call() |
没有 | exec 写 llm 节点 |
| exec providers | exec → session.run → agent_loop |
agent_loop 管 | 没写(bug) |
具体表现#
-
exec providers 路径不写 llm 节点。
_call_via_providers在session.run返回后直接 return,没调_append_model_call_node。wiki_agent 走这条路径,DAG 里 wiki_agent 直接连 wiki_agent,中间没有 LLM 节点。 -
dispatcher 路径写的不是 DAG llm 节点。dispatcher 调
persist_assistant_message写的是 SessionDB 的 assistant 消息(带 token 统计),不是 DAG 的Call(role=llm)节点。 -
legacy 路径没有 tool loop。模型只能返回纯文本,不能调工具。
设计#
核心原则#
一次 runtime.exec = 一个 llm 节点。
llm 节点和 code 节点是同一个抽象:一次调用,进入时 running,结束时回填 output。内部发生了什么(tool loop 跑了几轮、调了哪些工具)都是内部过程,不拆成多个节点。
进入 exec → 写 llm 节点 (status=running, output=None)
exec 内部 → agent_loop 跑 LLM + tool loop
(工具的 code 节点 called_by 指向此 llm 节点)
exec 返回 → 回填 llm 节点 (output=最终回复, status=success)
调用树示例#
wiki_agent 递归场景,统一后的 DAG:
llm (dispatcher 调 exec,模型决定调 wiki_agent)
code wiki_agent d1 (工具执行)
llm (wiki_agent 内部调 exec,模型又调 wiki_agent)
code wiki_agent d2 (递归)
llm (又一次 exec)
code wiki_agent d3
...
每两个 code 节点之间都有一个 llm 节点,调用链完整。
统一路径#
runtime.exec(content=[...])
→ 写 llm 节点 (status=running, output=None)
→ 构建上下文 (render_context + render_dag_messages + content)
→ 调 LLM,跑 tool loop 直到模型返回纯文本
(tool loop 内部模型调的工具,其 code 节点 called_by 指向此 llm 节点)
→ 回填 llm 节点 (output=最终回复, status=success)
→ 返回文本
各层改动#
dispatcher:不再直接调 agent_loop,改成调 runtime.exec。保留的职责:
- 写 user 节点
- 设置 session 上下文(ContextVar)
- 调 runtime.exec
- turn 级别善后(标题生成、compaction 触发)
process_user_turn (改后)
→ 写 user 节点 (不变)
→ 设 _store / _current_runtime (不变)
→ runtime.exec(content=用户消息) ← 改这里
→ finalize
runtime.exec:统一为一条路径。
- legacy
call=my_func包装成轻量 provider adapter,统一走_call_via_providers _call_via_providers补上 llm 节点写入(配对:进入写 running,返回回填)- 删掉 legacy 分支
agent_loop:不变。纯粹的"LLM 调用 + tool 执行"引擎,不关心 DAG。
现状详细分析#
路径 1: 常规聊天 (dispatcher)#
用户发消息
→ process_user_turn (dispatcher/__init__.py:96)
→ 写 user 节点到 DAG (db.append_message)
→ 设 _store ContextVar
→ _run_loop_blocking (dispatcher/__init__.py:640)
→ 构建 AgentContext (tools, system_prompt, history)
→ agent_loop([prompt], context, config) (agent_loop.py:232)
→ _stream_assistant_response → LLM 回复
→ 如果是 tool_use → _execute_tool_calls → tool.execute
→ 如果工具是 @agentic_function → wrapper 写 code 节点
→ 工具返回 → agent_loop 继续
→ 最终拿到纯文本回复
← 返回 final_text
→ persist_assistant_message (persistence.py:31)
→ 写 assistant 消息到 SessionDB (不是 DAG llm 节点)
→ finalize
路径 2: runtime.exec legacy#
@agentic_function 函数体里调 runtime.exec(content=[...])
→ exec (runtime.py:789)
→ self._call(content) → 用户自定义函数,返回文本
→ _append_model_call_node(reply=...) → 写 llm 节点到 DAG
← 返回文本
路径 3: runtime.exec providers#
@agentic_function 函数体里调 runtime.exec(content=[...])
→ exec (runtime.py:789)
→ _call_via_providers (runtime.py:1306)
→ 构建 AgentSession
→ session.run(current) → 内部跑 agent_loop (同路径 1 的代码)
← 返回 final assistant message
→ return _assistant_text(final) ← 没有写 llm 节点!
← 返回文本
节点写入与 callable 路径#
exec 配对写 llm 节点#
exec 在 sync 和 async 两条路径上配对开关 llm 节点(_open_model_call_node /
_close_model_call_node),函数体内发起的 LLM 调用因此出现在 session DAG 里。
_call_via_providers 中在 session.run 之前把 _call_id 切到 llm 节点,tool loop 的
工具 code 节点 called_by 正确指向 llm 节点
(test_tool_loop_subcall_attributes_to_llm_node)。
callable 路径走 provider model#
Runtime(call=fn) 被包装成一个 provider model,和其他模型一样走
_call_via_providers,因此不存在单独的 legacy 调用分支:
| 部件 | 是什么 | 文件 |
|---|---|---|
CallableModel adapter |
sync+async,把 pi-ai messages 转回 content 调用户 fn,返回单条 AssistantMessage,无 tool loop;补回 response_format→prompt suffix |
openprogram/providers/callable_model.py |
| Runtime 接线 | Runtime.__init__ 里 call= → self.api_model = CallableModel(call),callable runtime 强制 toolset="none" |
runtime.py init |
_call_via_providers 不忽略 content:_render_history_messages(content) 把 content
作为当前 turn(runtime.py:1451),无 store 时 _build_pi_context(content)(:1456)。
所以 adapter 不需要特殊处理 content,AgentSession 会把 history+current 给 model,
adapter 转回 content 调用户 fn。
stream_fn 注入#
stream_fn 参数穿过 exec → _call_via_providers → AgentSession.init →
AgentOptions,让 exec 可注入流(test_exec_stream_fn_injection)。
为什么 dispatcher 不折叠进 runtime.exec#
让 _run_loop_blocking 改调 runtime.exec 行不通,有四个原因:
- 模型会分叉:dispatcher 用
_resolve_model(agent_profile, req.model_override)(agent profile + 用户选的模型),而 attached runtime 是create_runtime()无参自动探测的,api_model不一致。走 exec 会用错模型。 - 上下文会冲突:dispatcher 用 context-engine 准备好了消息(
prep.agent_messages),exec 自己从 DAG render 历史,两套会打架。 - 流式事件不兼容:exec 的
on_stream发 flat dict,dispatcher 的on_event要 webui envelope。 - 最关键——会写重复节点:试着在 dispatcher 里用
_open/_close_model_call_node加一个 llm 节点,结果 DAG 里出现[user, assistant, assistant]——因为 dispatcher 的 assistant 会话消息(persist_assistant_message)本身就是顶层 LLM 调用的 DAG 记录。再加 llm 节点就重复了。test_dispatcher_integration.py::test_real_loop_text_only直接抓到这个重复。
结论:dispatcher 顶层 LLM 调用已经有 DAG 表示(role=assistant 的会话消息节点),工具调用挂在它下面。不需要也不应该再加 llm 节点。"code→code 缺 llm 节点"问题只发生在 @agentic_function 内部 exec 的 tool loop,由上文的配对 llm 节点写入覆盖。dispatcher 保持现状。
dispatcher 和 exec 的关系不是"dispatcher 走 exec",而是两个并列的 LLM 调用入口,各自把顶层调用记进 DAG(dispatcher → assistant 会话节点;exec → llm 节点),共享下层的 agent_loop 引擎和 DAG 节点写入 API。这跟其他框架一致(OpenClaw 也有独立 dispatcher 层)。
Agent 类:不动#
Agent 在 exec 下层(exec → AgentSession → Agent → agent_loop),是循环驱动器不是平行 LLM 路径,只在 session.py:94 构造一处。折叠它等于重写循环,无收益。
事件层:无改动#
tool.before 只在 _execute_tool_calls(agent_loop.py:518)一处 fire,所有路径都经过它。CallableModel 无内部 tool loop(用户 fn 只返回 str),不会重复/丢失事件。
敏感点#
- dispatcher 测试 seam:stream_fn 穿过 4 层后,
_run_loop_blocking仍是 patch 入口,测试的 stream_fn 经 exec 流到 model。test_dispatcher_dag_attach.py整体替换_run_loop_blocking,不受影响。 - prompt-cache 前缀稳定:override 不能破坏 DAG-prefix 缓存。
- response_format:
claude_call/gemini_call的 JSON-mode 靠 adapter 补回 suffix。
统一"记一次模型回复"的写入#
同一件事两套写法#
"记录模型回复"现在有两套配对写节点(开占位 running → 回填结果)的实现,按入口分:
| 入口 | 开占位 | 回填 | 实现 |
|---|---|---|---|
| dispatcher(聊天) | insert_placeholder(_turn_lifecycle.py:65) |
persist_assistant_message(persistence.py) |
dispatcher 本地 |
| exec(代码) | _open_model_call_node(runtime.py:631) |
_close_model_call_node(runtime.py:656) |
runtime 原语 |
两套结构一模一样(open→close 配对),但一套散在 dispatcher、另一套在 runtime,同样的头尾写入存在两份。
关键事实(让统一变简单)#
存储层没有 ROLE_ASSISTANT——只有 user/llm/code(context/nodes.py:36-40)。聊天回复早就存成 ROLE_LLM:_msg_to_node 把 role="assistant" 映射成 ROLE_LLM,_node_to_msg 读回来默认还原成 "assistant"(_msg_adapter.py)。exec 的 _open_model_call_node 写的也是 ROLE_LLM,读回来同样默认 "assistant"。
两个原语在节点层早就用同一个 role。 唯一差异是 dispatcher 多写了 4 样元数据,exec 的裸原语没写。序列化只有一个收口点 _node_to_msg → 前端读到的还是 "assistant"。
一个通用配对原语,两个入口共用#
exec 的配对原语升级为通用配对原语(能装下 dispatcher 需要的字段),两个入口都用它,重复的 placeholder/persist 机制随之取消。
统一原语必须保留这 4 样(否则前端/分支/计量会断):
| 字段 | 谁要 | 风险 |
|---|---|---|
extra.blocks(thinking/text/tool 卡片顺序) |
前端气泡主体(conv-mapper.ts) | 最高 |
| token 列 + token_model | 计量 UI | 高 |
metadata.parent_id=user_msg_id |
分支/fork/rewind 的 active-branch 重建 | 高 |
cancelled/completed 终态(exec 当前写 success) |
用户停止时的部分输出 | 中 |
原语签名升级(可选参数,exec 不传、dispatcher 传):
open_model_call_node(*, role="llm", parent_id=None, content_text="", model=None) -> node_id
close_model_call_node(node_id, *, reply, status="success", blocks=None, usage=None)
前端无需改动:_node_to_msg 仍输出 "assistant",前端读的字段(blocks/token/parent_id)都还在,只是改由统一原语写。
实现状态#
已具备:
- exec 在 sync 与 async 两条路径上的配对 llm 节点写入,以及
_call_id切换使工具 code 节点归属到 llm 节点 CallableModeladapter 及把call=路由到_call_via_providers的 Runtime 接线stream_fn穿过 exec → _call_via_providers → AgentSession → AgentOptions
统一配对原语尚未被 dispatcher 采用,落地顺序为:
- 升级
_open/_close_model_call_node,加parent_id/blocks/usage/status可选参数(exec 调用不变,默认行为不变) - dispatcher 的
insert_placeholder改成调open_model_call_node(role="assistant", parent_id=user_msg_id) - dispatcher 的
persist_assistant_message改成调close_model_call_node(blocks=..., usage=..., status="completed"/"cancelled")——只保留字段组装,删掉自己的 append/update - 删
_turn_lifecycle.py的 placeholder/error-fold 里重复的写节点逻辑 - 验证:webui 端到端聊天(气泡、thinking/tool 卡片、token、停止、fork)全部正常 + 全量 pytest
涉及 legacy 调用 seam 的测试:test_openai.py:61、test_anthropic.py:63、test_gemini.py:68 去掉 _uses_legacy_call() is False;test_decision.py:24、test_loop_options.py:153、test_dispatcher_dag_attach.py:89 去掉 _uses_legacy_call→True override,保留 _call override。需重验的行为类:test_runtime_exec_dag.py:34、test_functions.py 的 _mock_call(按 content shape 分支,adapter 原样传 content)、conftest.py 的 echo_call/noop_call。
相关文件#
openprogram/agentic_programming/runtime.py— exec / _call_via_providers / _open|_close_model_call_node(统一原语所在)openprogram/providers/callable_model.py— CallableModel adapteropenprogram/agent/agent_loop.py— agent_loop / _execute_tool_callsopenprogram/agent/session.py— AgentSessionopenprogram/agent/dispatcher/__init__.py— process_user_turn / _run_loop_blockingopenprogram/agent/dispatcher/persistence.py— persist_assistant_message(改调统一原语)openprogram/agent/internals/_turn_lifecycle.py— insert_placeholder / fold_error(删重复写入)openprogram/store/session/_msg_adapter.py— _node_to_msg(序列化收口点,role 还原)openprogram/agentic_programming/function.py— @agentic_function wrapper