与第三篇的映射:Cordis 机制在本文中对应什么?

在进入正文之前,先回答一个读者很可能有的疑问:“第三篇讲的 Cordis 机制(Fiber、Effect、Event),在本文里到底对应什么?”

这是一个快照映射表,不是完整的对应关系,但能帮你把两篇文章的认知衔接起来:

Cordis 概念 在本文中的体现
Fiber(生命周期) 每个 Turn 和 Step 的边界归属。turn/end 写在 finally 中、无论成功失败都写入,正是 Fiber 的 UNLOADING 状态在 Agent Loop 层面的投射。
Effect(可逆副作用) step/end 的边界清理、tool/calltool/result 的配对写入、turn/end 作为所有副作用的封口。
Event(控制流) agent/pre-stepagent/requestagent/request-erroragent/turn-stopping 等控制点,都是 Event 机制在 Agent Loop 中的具体应用。

有了这个映射,你在阅读下面关于 Turn/Step 边界的讨论时,可以随时回看这张表,理解"为什么每一步都要写在 Session 里"——因为 Fiber 的生命周期边界就是通过 turn/start/turn/endstep/start/step/end 这些持久化事件来标记的。

引言:为什么需要专门追这条链路?

前三篇讨论的是 DSH 的"静态"部分:

  • 第一篇:Profile、Bundle、Patch、Loader 如何组装出一个可运行的 Agent
  • 第二篇:Cordis 的 Fiber、Context、inject、provide、effect 如何管理插件的生命周期
  • 第三篇:Agent 运行时主链路全景

现在是第四篇,进入"动态"部分。

你在聊天框里敲了一句话,按下发送键。

然后呢?

这句话什么时候变成一次模型调用?模型返回了工具调用,工具执行完后结果去了哪里?如果用户在工具执行过程中又发了一条消息,那条消息是开一个新任务,还是插进当前任务里?点了一下取消,正在跑的模型请求和正在执行的工具,是立刻停还是等结算?

这些问题的答案,决定了 Agent 能不能用来做产品,而不只是跑 Demo。

大多数 Agent Demo 用一个 while (true) 循环来处理:

while (true) {
  const answer = await llm(messages, tools)
  if (!answer.toolCalls.length) return answer
  messages.push(await runTools(answer.toolCalls))
}

这段代码核心逻辑没错——Agent 本来就是"模型→工具→模型"的循环。但它省略了生产环境必须回答的问题:

  • 消息是同时进来的,还是顺序进来的?并发怎么处理?
  • messages 数组存在内存里,进程崩溃了怎么办?
  • 工具执行时用户补充了一条信息,是追加到当前任务还是新开任务?
  • 用户点了取消,已经发出的模型请求怎么处理?已经启动的工具呢?
  • 插件想在这个流程的某个节点插一脚(比如鉴权、限流、日志),在哪里插?
  • 异常发生后,日志里能看到"任务曾经走到过哪一步"吗?

这些问题指向同一个方向:Agent Runtime 需要显式的边界,而不是隐式的内存状态。

DSH 给出的边界是四个概念:Inbox → Turn → Step → Session Event Log。

这四个概念把一条消息从"用户发出"到"任务结束"的完整生命周期,划分成了有明确职责的阶段。本文追踪主链路,Session 的恢复和回放留到下一篇。

源码地图:读源码时,按这张地图走

在进入逐节拆解之前,先给出本文对应的源码位置和阅读顺序。正文中的每一节都会落到具体的源码文件和函数上。

对应文件:

  • packages/core/agent-loop/src/agent.ts —— 主循环入口
  • packages/core/agent/src/inbox.ts —— Inbox 队列实现
  • packages/core/agent-loop/src/tool-calls.ts —— 工具执行与结果入队
  • packages/core/agent-loop/tests/interception.spec.ts —— pre-step 拦截测试
  • packages/core/agent-loop/tests/request-error.spec.ts —— 请求错误恢复测试
  • packages/core/agent-loop/tests/tool-order.spec.ts —— 工具顺序测试

推荐阅读顺序:

1. followup / steer / inject        → agent.ts: send()
2. Inbox.claim()                    → inbox.ts: claim()
3. wakeDriver() -> kick() -> turn() → agent.ts: wakeDriver(), kick(), turn()
4. preStep()                        → agent.ts: preStep()
5. step()                           → agent.ts: step()
6. buildRequest()                   → agent.ts: buildRequest()
7. executeToolCalls()               → tool-calls.ts: executeToolCalls()

1. 链路全景图:先看到全貌,再进入细节

在逐段拆解之前,先看一张完整的时序图。这张图展示了"一次普通任务"从 followup()turn/end 的全部路径。

核心原则:运行时决策不持久化,但决策的事实结果(放行了、拒绝了、异常了)会被记录。

注意两个概念:

  • 实线箭头:写入 Session Event Log —— 持久化,可恢复,是"事实"
  • 虚线箭头:控制流 —— 内存中生效,不持久化,是"决策"
Session Event Log Tool Runtime LLM Adapter agent/* 插件 SystemPrompt Inbox ReactLoopAgent 用户/入口 Session Event Log Tool Runtime LLM Adapter agent/* 插件 SystemPrompt Inbox ReactLoopAgent 用户/入口 opt [有 tool-call] alt [拒绝] [放行] followup("帮我查天气") 写入 next-turn wakeDriver() turn/start(持久事实:任务开始了) claim(一个 next-turn) assemble() agent/pre-step(控制点①:是否放行?) turn/end(blocked) step/start(持久事实:一次模型请求开始了) user/message(持久事实:用户说了什么) agent/request(控制点②:是否改写请求?) stream(request) assistant/chunk assistant/chunk(持久事实:模型输出了什么) assistant/message executeToolCalls() tool/call + tool/result(持久事实:工具调用了什么、返回了什么) additionalContexts → next-step(待办:下一步要处理这些) step/end(持久事实:一次模型请求结束了) turn/end(持久事实:整个任务结束了)

1.1 先认识图中的每个角色

角色 它是什么? 在链路中负责什么?
User 用户或上层入口 发起消息的那个人或系统
ReactLoopAgent Agent 循环的核心 整个流程的"总调度器",负责驱动每一步
Inbox 待办工作队列 存放还没被处理的消息,可持久化恢复
SystemPrompt 系统提示词组装器 把各段 System Prompt 拼成完整的上下文
agent/ 插件* 拦截器插件 在关键节点插入自定义逻辑(鉴权、改写等)
LLM Adapter 模型适配器 真正调用大模型 API
Tool Runtime 工具运行时 执行模型请求的工具(如 get_weather)
Session Event Log 事件日志 所有持久化事件的最终归宿

1.2 逐段解读

第一阶段:消息入队

User → Agent: followup("帮我查天气")
Agent → Inbox: 写入 next-turn
Agent → Agent: wakeDriver()

用户发送了一条消息。followup() 把消息放进 Inbox 的 next-turn 队列,然后调用 wakeDriver() 唤醒 Agent 循环(因为 Agent 不是一直在运转,不然会占用 CPU 资源)。

此时只做了一件事:消息被存下来了,但还没开始处理。

第二阶段:打开 Turn

Agent → Log: turn/start(持久事实)
Agent → Inbox: claim(一个 next-turn)
Agent → Prompt: assemble()
Agent → Hook: agent/pre-step(控制点①)

Agent 循环被唤醒后:

  1. 先写 turn/start:不管后续能不能处理,先记录"有一个任务开始了"。这是为了审计和排错——即使最终被拒绝,日志里也有迹可循。
  2. 从 Inbox 领取消息:按照上一节的规则,取一个 next-turn 消息(当前 Turn 的输入)。
  3. 组装 System Prompt:把各段提示词拼成完整的上下文。
  4. 进入 agent/pre-step 钩子:这是第一个控制点。插件可以在这里决定:
    • reject:拒绝这个 Turn(比如鉴权失败)
    • enter(messages):放行,并可以改写输入

第三阶段:决策分支

alt 拒绝
    Agent → Log: turn/end(blocked)
else 放行
    Agent → Log: step/start

如果 pre-step 返回 reject,Turn 直接结束,日志里只有 turn/start + turn/end(blocked)没有 Step,没有模型请求,没有 user/message 事件。

如果返回 enter,Turn 正式进入执行阶段:

  • step/start:记录"一次模型请求开始了"
  • user/message:记录"用户说了什么"(注意:这一步在 pre-step 放行之后,所以被拒绝的消息不会污染历史

第四阶段:模型请求与响应

Agent → Hook: agent/request(控制点②)
Agent → LLM: stream(request)
LLM → Agent: assistant/chunk
Agent → Log: assistant/chunk
Agent → Log: assistant/message

放行后,进入模型调用阶段:

  1. 进入 agent/request 钩子:第二个控制点。插件可以在这里改写模型请求(比如换模型、调整参数),但最终必须提供合法的 provider 和 model。
  2. 调用 LLM Adapter:真正向大模型发起流式请求。
  3. 每个 chunk 都写日志:模型输出的每个片段,都会先写入 assistant/chunk,最后汇总成一条 assistant/message

关键设计:流式输出既让 UI 实时显示,又保证恢复时能重建完整消息。

第五阶段:工具调用(如果存在)

opt 有 tool-call
    Agent → Tools: executeToolCalls()
    Tools → Log: tool/call + tool/result
    Tools → Inbox: additionalContexts → next-step

如果模型返回了工具调用:

  1. 执行工具:Tool Runtime 执行 get_weather 之类的函数。
  2. 写工具事件tool/call(调用了什么)、tool/result(返回了什么)都写入日志。
  3. 写入 next-step:工具结果作为"待办工作"放进 Inbox 的 next-step 队列。

这一步是整个链路的"续命"机制:工具结果不是简单返回,而是被放进 Inbox,作为下一步的输入。所以 Loop 会在同一个 Turn 内继续运行,进入下一个 Step。

第六阶段:收尾

Agent → Log: step/end
Agent → Log: turn/end

无论是否有工具调用,最终都会:

  • step/end:这次模型请求结束了
  • turn/end:整个任务结束了

1.3 这张图回答了什么?

把六个阶段串起来,这张图回答了我们一开始提出的那些问题:

问题 答案在图中
消息如何进入系统? followup → Inbox.next-turn
消息何时变成模型请求? pre-step 放行后,进入 step/start → agent/request → LLM
工具结果如何推动下一步? 工具结果写入 Inbox.next-step,下一轮被 claim
插件在哪里拦截? agent/pre-step(放行/拒绝)、agent/request(改写请求)
失败/取消怎么不卡在半截? turn/end 在 finally 中写入,带 reason(图中略,见后续)
进程崩溃后如何恢复? 每一步都写了日志,回放即可重建状态

1.4 两个贯穿全文的基础区分

进入细节之前,先建立两个区分。它们会出现在每一段里,提前理解能避免混淆。

区分一:Turn vs Step

概念 定义 寿命
Step 一次模型请求 + 该请求产生的所有工具执行 从发送模型请求开始,到模型返回(含工具结果)结束
Turn 一个完整任务周期,包含 0 到 N 个 Step 从 Inbox 领取工作开始,到不再欠任何工作为止

举例:用户说"帮我查今天天气,再根据天气推荐一件外套"

Turn(整个任务:查天气 → 推荐穿搭)
  ├─ Step 1:模型调用 get_weather → 执行 → 返回"晴,25°C"
  └─ Step 2:模型调用 recommend_outfit → 执行 → 返回"短袖+薄外套"

这是一个 Turn,两个 Step。不是两个独立的"对话轮次"。

这个区分之所以重要,是因为它回答了两个工程问题:

  • Step 的边界是模型请求step/end 表示"一次模型请求及其工具调用结束了",但不代表任务结束
  • Turn 的边界是任务完成turn/end 表示"用户这个请求已经处理完了,不再欠任何工作"

区分二:持久事实 vs 实时控制

类型 包含什么 是否落盘
持久事实 turn/startstep/startuser/messageassistant/messagetool/calltool/resultturn/end 写入 Session Event Log
实时控制 agent/pre-stepagent/requestagent/turn-stoppingagent/request-error 仅在运行期内存中生效

在代码里,agent/* 前缀属于控制流,不带前缀的 turn/*step/* 属于持久流。命名约定本身就是边界标记。

有了这两个基础认知,我们进入第一站:消息进入系统后,第一站是哪里?

2. 消息进入系统的第一站:Inbox

2.0 三个 API 的定义

在进入详细讨论之前,先明确三个 API 的语义。它们贯穿全文,理解它们能避免后续混淆:

API 定义 写入位置 是否唤醒 driver 使用场景
followup(message) 向 Agent 发送一条新消息,作为新任务后续对话的输入 next-turn 用户发新消息
steer(message) 向 Agent 发送一条补充信息,插入到当前 Turn 的下一步中处理 next-step 工具执行期间补充条件
inject(message) 向 Agent 注入一条消息,仅作为上下文,不主动唤醒处理 next-step 系统注入背景信息,等下次唤醒时被自动消费

这三者的区别决定了"这条消息会被当成一个新任务(followup),还是当前任务的补充(steer),还是被动的上下文(inject)"。

followup() 是本文追踪的主入口,steer()inject() 会在"工具执行期间用户补充信息"的场景中出现。

有了这三个定义,现在看 Inbox 的完整设计。

上一节我们建立了两个基础区分:Turn vs Step,持久事实 vs 实时控制。现在看消息进入系统后的第一站。

一个直觉的设计是:用户发消息 → 直接传给 Agent 循环 → 开始处理。

DSH 没有这样做。它在入口和 Agent 循环之间加了一层:Inbox

用户消息 → Inbox(待办队列)→ Agent 循环领取(claim)→ 执行 Turn

这就像餐厅后厨的订单架:客人点单后,订单先放在架子上,厨师从架子上取单开始做菜,而不是每来一单就直接塞到厨师手里。

2.1 为什么需要 Inbox?

直接处理的问题在于:消息在内存里。

如果消息刚到达、还没来得及处理,进程崩溃了,这条消息就永远消失了。在 Agent 场景里,丢失消息的直接后果是"用户以为 Agent 收到了指令,但 Agent 根本没开始处理"。

DSH 的 Inbox 不是内存队列。每次对 Inbox 的修改(插入、删除、取消)都会先追加一个 agent/inbox/spliced 事件到 Session Event Log,然后才更新内存投影。

重启后,Inbox 从 Session 日志中回放这些操作,恢复到崩溃前的状态。这样,即使在工具执行期间进程崩溃,系统重启后仍然知道"有一条补充消息已经收到、尚未消费",并会继续处理。

这条规则把 Inbox 和普通内存队列区分开来:它是可恢复的调度状态。

2.2 三条队列和领取规则

Inbox 维护三条逻辑队列:next-turnnext-step、以及用于持久化操作的内部队列。

next-turnnext-step 的区别:

队列 写入方式 用途 消费方式
next-turn followup() 新任务或后续对话 每个 Turn 的首个 Step 取一条
next-step steer() / inject() 当前 Turn 内补充工作 每个 Step 取全部

领取规则:

每次 Step 开始时:
  1. 取走 next-step 队列中的所有消息(全部消费)
  2. 如果这是当前 Turn 的第一个 Step,额外取走一个 next-turn 消息

这个规则解决了并发场景下的一个关键问题:工具执行期间,用户连续发了两条补充信息,它们应该进入同一个 Turn 的同一个 Step,而不是被拆成两个 Step。

如果所有消息都混在一起,无论怎么定义消费顺序都会遇到矛盾:全部消费会吞掉本该属于下一个 Turn 的消息,只取一个则无法在同一 Turn 内合并多条补充信息。两条队列和领取规则把这个权衡显式化了。

2.3 followupsteerinject 的写入语义

现在用两条队列重新理解这三个 API:

  • followup(message):把消息写入 next-turn,调用 wakeDriver()。当前 Turn 结束后,下一轮的第一个 Step 会领取这条消息作为新任务的输入。
  • steer(message):把消息写入 next-step,调用 wakeDriver()。如果当前正在执行某个 Turn,消息会在当前 Step 结束后、下一步开始时被立即消费;如果没有正在执行的 Turn,消息会等待下一个 Turn 的第一个 Step 中被消费。
  • inject(message):把消息写入 next-step,但调用 wakeDriver()。消息会留在队列里,直到其他操作(比如 followupsteer)唤醒 Agent 循环时被一并消费。

steer() 的典型场景是:工具正在执行(比如一个耗时的文件搜索),用户发现漏说了一个条件,补充了一句"只搜 .ts 文件"。steer() 让这条补充信息进入当前 Turn 的下一步,而不是另开一个独立的 Turn。这样,模型在下一步里同时看到工具返回的结果和用户补充的条件,能够综合判断。

2.4 入队和消费都是持久化事件

Inbox 的每个操作都会触发持久化:

入队:session.append('agent/inbox/spliced', { mutation: 'splice', queue: 'next-turn', ... })
消费:session.append('agent/inbox/spliced', { mutation: 'splice', queue: 'next-step', deleteCount: n, ... })
取消:session.append('agent/inbox/spliced', { mutation: 'clear' })

这些事件写入 Session 后,Inbox 的内存投影才会更新。重启后,回放这些 spliced 事件就能重建 Inbox 的完整状态。

这意味着 Inbox 的每一条消息都是可追溯的:它什么时候被创建、什么时候被消费、什么时候被取消,都记录在日志里。对审计和排错来说,这不是锦上添花,而是基础要求。

本节小结

概念 说明
Inbox 可恢复的待办工作队列,不是易失内存
next-turn 新任务的入口,followup() 写入此队列
next-step 当前 Turn 内补充工作的入口,steer() / inject() 写入此队列
领取规则 每次 Step 消费全部 next-step +(首个 Step 时)一个 next-turn
持久化 每次修改先写 Session 日志,再更新内存投影

3. Turn 的打开与 Step 的创建:从 wakeDriver()pre-step

上一节我们走到:用户消息进入 Inbox,followup() 调用 wakeDriver() 唤醒 Agent 循环。

现在看被唤醒之后发生了什么。

3.1 wakeDriver()turn() 的启动

wakeDriver() 将 Agent 状态从 idle 置为 running,随后 kick() 开始循环调用 turn()

turn() 是整条链路的"总指挥"。它负责:

  1. 打开一个 Turn(写入 turn/start
  2. 从 Inbox 领取工作(next-turn + next-step
  3. 准备模型请求(组装 Prompt、运行时上下文)
  4. 交给插件拦截(agent/pre-step
  5. 如果放行,创建 Step 并执行

reject

enter

wakeDriver

kick

turn函数

写入 turn/start

Inbox领取消息

组装SystemPrompt

agent/pre-step拦截

写入 turn/end blocked

创建Step并执行

3.2 turn/start 为什么写在最前面?

一个直觉的设计是:先确认消息确实能被处理,再记录"任务开始了"。

DSH 选择了相反的顺序:turn() 的第一件事就是写入 turn/start领取 Inbox 消息和 agent/pre-step 拦截之前。

// agent.ts - turn() 简化逻辑
async turn() {
  // 第一步:写入 turn/start
  await this.session.append('turn/start', { turn: this.currentTurn })
  
  // 第二步:从 Inbox 领取消息
  const claimed = await this.inbox.claim()
  
  // 第三步:进入 pre-step 拦截
  const decision = await this.preStep(claimed)
  // ...
}

为什么这样设计?

因为 agent/pre-step 可能拒绝这个 Turn。如果 turn/start 写在拦截之后,被拒绝的 Turn 在日志里就只剩下一条 turn/end(blocked),没有对应的开始记录。排错时你只能看到"有一条任务被终止了",但不知道它是什么时候来的、为什么被终止。

先写 turn/start,再执行拦截,保证每条进入系统的消息都有一个明确的开始标记。即使最终被拒绝,日志链路也是完整的:

turn/start (2025-01-01 10:00:00.123)
turn/end { kind: 'blocked', reason: 'auth failed' } (2025-01-01 10:00:00.456)

3.3 preStep() 的精确顺序

很多人会误以为 agent/pre-step 在 Prompt 组装之前执行,插件看到的是"原始用户消息"。

实际上并非如此。源码中 preStep() 的执行顺序是:

1. Inbox.claim():领取 next-turn + next-step 消息
2. SystemPrompt.assemble():将 System Prompt 的各段拼接成候选消息
3. RuntimeContextProjection:生成运行时上下文(当前会话的投影)
4. 进入 agent/pre-step waterfall:插件接收已组装好的候选消息和上下文
5. 插件返回决策:reject(拒绝)或 enter(messages)(放行并可选改写)

reject

enter

Inbox claim

SystemPrompt assemble

Runtime context projection

agent/pre-step waterfall

turn/end blocked

step/start

这个顺序意味着:agent/pre-step 拿到的是已经组装好的 Prompt 候选和运行时上下文,不是原始的用户消息。

拦截器拿到的是接近"最终请求"的东西,可以进行最后的裁决或改写。同时,如果 pre-step 选择了 reject,这些已经在内存中组装好的 Prompt 数据不会被写入 user/message 事件——它们随 Turn 被一同丢弃,不会污染会话历史。

3.4 reject 的边界语义:无 Step,无 user/message

agent/pre-step 返回 reject 时,Turn 的结束记录为:

{ kind: 'blocked' }

此时不会产生:

  • step/start
  • user/message
  • 任何模型请求

日志中只包含:

turn/start
turn/end { kind: 'blocked' }

这个边界的意义在于:被拒绝的消息不会伪装成"模型已经看到的历史"。

如果先写了 user/message 再拦截,日志里就会有一条"用户说了什么"的记录,但模型实际上从未处理过它。回放会话时,这段历史会被错误地重建。pre-stepstep/startuser/message 之前执行,从根本上避免了这个问题。

上游测试 tests/interception.spec.ts 验证了这一行为:使用 MockAdapter 时,pre-step reject 后请求数为 0,事件只有 turn/startturn/end,结束理由为 blocked

本节小结

关键点 说明
turn/start 最先写入 即使最终被拒绝,日志链路也完整
preStep() 先组装 Prompt,再执行拦截 拦截器看到的是已组装的候选,不是原始消息
reject 不产生 Step step/start、无 user/message、无模型请求
日志干净 被拒绝的 Turn 只有 turn/startturn/end(blocked)

4. Step 的创建、请求构建与拦截

上一节结束在:agent/pre-step 返回 enter,Turn 被放行。

现在进入真正执行模型请求的阶段——Step 的创建与执行。

4.1 pre-step 放行后发生了什么?

pre-step 返回 enter 之后,Agent Loop 按顺序执行三个动作:

  1. 写入 step/start:记录"一次模型请求开始了"。这是一个持久事实,用于恢复和审计。
  2. 写入 user/message:把进入 Step 的用户消息逐条写入 Session。这一步在 pre-step 放行之后才执行——被拒绝的消息不会出现在日志里,不会伪装成"模型已经看到的历史"。
  3. 调用 step():进入真正的模型请求执行流程。

pre-step 返回 enter

写入 step/start

写入 user/message

调用 step 函数

buildRequest 构建请求

stream 发起模型请求

4.2 buildRequest() 的四件事

step() 并不是立刻请求 LLM,而是先调用 buildRequest() 构建一个完整的请求对象。buildRequest() 按顺序完成四件事:

1. 以 AgentOptions 或已持久化 header 作为模型路由提案
2. 交给 agent/request waterfall 允许插件改写或替换提案
3. 让 LLM Adapter 的 prepareCall() 解析模型默认项
4. 把最终 header、context 与冻结 request 绑定到 Session

4.3 agent/request 是配置决策点,不是聊天消息钩子

agent/request waterfall 收到的是 LlmCallConfig——包含 providermodelreasoningEffortmaxTokens 等配置参数。

插件可以做两件事:

  • 替换模型:比如把 deepseek-v4-flash 换成 deepseek-v4-pro
  • 调整参数:比如把 reasoningEffortmedium 调到 high

但它最终必须保证 providermodel 是合法的。它不能返回一条"聊天消息"作为响应——它的返回值必须是 LlmCallConfig

// agent/request 的典型用法
ctx.on('agent/request', (config, next) => {
  // 改写提案:把复杂任务切到 Pro 模型
  if (config.model === 'deepseek-v4-flash' && isComplexTask) {
    config.model = 'deepseek-v4-pro'
  }
  return next(config)
})

agent/requestagent/pre-step 的区别:

agent/pre-step agent/request
作用范围 决定 Turn 是否进入、改写输入消息 决定请求用哪个模型、什么参数
输入 候选消息 + 运行时上下文 LlmCallConfig(provider/model/参数)
输出 rejectenter(messages) 修改后的 LlmCallConfig
调用时机 Step 创建之前 Step 创建之后、模型请求之前

4.4 请求为什么要冻结?

buildRequest() 最终以 deepFreeze() 构造 request,再附上 Session id 和 AbortSignal。

这不是防御式编程的装饰,而是在划定一条责任线:

离开 Loop 交给 Adapter 的请求,不能再被下游异步代码悄悄修改。

如果请求对象是可变的,下游代码(比如某个工具插件、某个拦截器)可能在不经意间修改 modeltools 字段,导致日志里记录的请求和实际发出的请求不一致。冻结之后,任何试图修改的行为都会直接报错,保证了请求的完整性和可审计性。

4.5 请求头写入 Session

构建完请求后,Loop 会把最终的 request/header 写入 Session:

request/header {
  provider: 'deepseek',
  model: 'deepseek-v4-flash',
  tools: ['get_weather', 'search_files'],
  reasoningEffort: 'medium',
  maxTokens: 8192
}

这意味着:

  • 每一步用了哪个模型、哪个 provider、哪些工具、什么参数,都记录在案
  • 回放时可以精确重建当时的模型请求状态
  • 排错时可以直接查看某次 Step 的请求配置,而不需要猜测

本节小结

关键点 说明
step/start 在模型请求之前写入 保证即使请求失败,也有"请求尝试过"的记录
user/messagepre-step 放行后写入 被拒绝的消息不会污染会话历史
buildRequest 四件事 读取提案 → agent/request 改写 → prepareCall 解析 → deepFreeze 冻结
agent/request 是配置决策点 改模型/参数,不改消息内容
请求冻结 防止下游异步篡改,保证日志与实际一致
请求头写入 Session 每一步的模型配置可追溯、可恢复

5. 模型流式响应、工具调用与下一步的触发

上一节结束在:请求被构建、冻结,交给 LLM Adapter 发起流式请求。

现在看模型返回后发生了什么。

5.1 两种情况:直接回答 vs 工具调用

模型返回的内容分两种:

  1. 没有 tool-call block:模型直接输出最终答案
  2. 有 tool-call block:模型要求调用工具

模型返回

有 tool-call?

写入 assistant/chunk
汇总为 assistant/message

写入 step/end

写入 turn/end

Turn 完成

写入 tool/call

执行工具

写入 tool/result

结果 → Inbox.next-step

写入 step/end

Turn 继续
下一轮 Step

5.2 情况一:没有 tool-call,直接回答

模型输出最终答案时,处理流程如下:

  1. 流式写入 chunk:每个 assistant/chunk 实时写入 Session,支持 UI 流式渲染
  2. 汇总为完整消息BlockAssembler 将所有 chunk 汇总为一条 assistant/message
  3. Step 结束:写入 step/end
  4. Turn 结束:进入 agent/turn-stopping 检查点后,写入 turn/end

此时没有待办工作(Inbox 为空),任务完成。

5.3 情况二:有 tool-call——结果进入 Inbox.next-step

这是 Agent 多步推理的核心机制。当模型输出工具调用时:

第 1 步:持久化 tool-call

Loop 先写入 tool/call 事件,记录模型要求调用哪个工具、参数是什么。

第 2 步:执行工具

Tool Runtime 执行对应的工具函数(如 get_weather('北京')),获得返回结果。

第 3 步:持久化 tool-result

把工具返回的结果写入 tool/result 事件。

第 4 步:结果入队 Inbox(关键!)

工具结果被放入 Inbox 的 next-step 队列,作为"下一步要处理的工作"。

这一步是整个链路的"续命"机制——工具结果不是直接塞回模型,而是通过 Inbox 进入下一轮 Step。

模型输出 tool-call

写入 tool/call

执行工具

写入 tool/result

放入 Inbox.next-step

Step 结束,Turn 继续

下一轮 Step 领取 next-step

5.4 为什么工具结果要入队而不是直接返回?

这是 DSH 链路中最重要的设计决策之一。直觉上,工具执行完应该直接把结果塞回模型。但 DSH 选择了另一种方式:结果入队,让 Loop 在下一轮自然领取。

方案 直接返回 入队 Inbox
实现方式 工具结果 → 直接追加到 messages 数组 工具结果 → Inbox.next-step → 下一轮 Step
统一性 工具结果是"特例",需要单独写一套处理逻辑 工具结果和用户消息走同一个通道,逻辑统一
可恢复性 工具结果在内存里,进程崩溃则丢失 工具结果已写入 Session,重启后可恢复
并发处理 多个工具同时返回时,需要手动协调 Inbox 队列自然管理顺序

5.5 工具执行内部边界的简要说明

在 DSH 中,工具执行并不是"调用一个函数就完事了"。工具执行内部有四个边界点,在本文中我们不展开,但你需要知道它们存在:

边界点 做什么 在源码中的位置
权限检查 工具是否允许在当前会话/用户下执行 tool-calls.ts: checkPermissions()
审批流程 高风险工具可能需要用户确认才能执行 tool-calls.ts: requiresApproval()
超时控制 工具执行超过设定时间则中断 tool-calls.ts: executeWithTimeout()
并发调度 多个工具调用的并行度控制 tool-calls.ts: scheduleConcurrent()

这些边界会在后续专文中详细展开。在本文中,你只需要知道:executeToolCalls() 返回的结果已经经过了这些边界处理,最终输出的 tool/result 是"经过系统校验后的最终结果"。

5.6 流式 chunk 的日志策略

对每个流式 chunk,Loop 会:

  1. 立即写入 assistant/chunk 事件(支持 UI 流式渲染)
  2. 交给 BlockAssembler 汇总
  3. 流结束后,汇总为一条 assistant/message

assistant/message 保留 sourceEventSeqs,指向构成它的所有 chunk。

assistant/chunk { text: "今天" }
assistant/chunk { text: "天气" }
assistant/chunk { text: "晴" }
...
assistant/message { text: "今天天气晴,25°C", sourceEventSeqs: [seq1, seq2, seq3, ...] }

这保证了:

  • 实时体验:UI 能在每个 chunk 到达时立即显示
  • 可恢复事实:回放时可以直接使用汇总后的 assistant/message,无需重新聚合

5.7 agent/turn-stopping 是什么?

在 Step 结束、Turn 写入 turn/end 之前,DSH 会进入一个额外的控制点:agent/turn-stopping

这是一个 serial 钩子。它的作用是:在 Turn 即将结束时,给插件最后一次机会注入新的工作,或者改写 Turn 的结束理由。

典型的用法:

  • 目标管理插件检查"任务目标是否真的完成了",如果没有,注入一个新的 next-step 让 Turn 继续
  • 循环控制插件检测到可能死循环,主动结束 Turn 并标记 { kind: 'stopped_by_policy' }

如果 agent/turn-stopping 注入了新的 next-step,Turn 不会结束,Loop 会继续进入下一轮 Step。如果没有任何注入,Turn 正常写入 turn/end

这个控制点的存在,使得"任务完成"的判断权不完全在 Agent Loop 内部,而是可以由外部插件根据业务逻辑动态决定。

5.8 step/endturn/end 的边界分工

事件 含义 写入时机
step/end 一次模型请求(及其工具调用)结束 每轮 Step 完成后
turn/end 整个任务周期结束 无待办工作且无 next-step

有 tool-call 时step/end 写入后,Turn 不结束(因为 next-step 有工具结果待处理)。Loop 进入下一轮 Step。

没有 tool-call 时step/end 写入后,进入 agent/turn-stopping,如果无新工作注入,Turn 结束,写入 turn/end

5.9 多 Step Turn 的完整日志

一个包含工具调用的 Turn,日志结构如下:

turn/start
step/start
  user/message: "帮我查北京天气"
  assistant/chunk*
  assistant/message: [tool-call: get_weather]
  tool/call: { name: "get_weather", args: { location: "北京" } }
  tool/result: { result: "晴,25°C" }
step/end
step/start
  assistant/chunk*
  assistant/message: "今天北京天气晴,25°C"
step/end
turn/end

中间的 step/startstep/end 重复出现,但 turn/startturn/end 只各出现一次。

本节小结

关键点 说明
无 tool-call 流式写入 chunk → 汇总为 message → step/end → turn/end
有 tool-call 写入 tool/call → 执行工具 → 写入 tool/result → 结果入队 Inbox.next-step
工具结果入队 而非直接返回,保证统一性、可恢复性、顺序性
工具执行有四个内部边界 权限、审批、超时、并发调度(后续专文展开)
chunk 先写日志 实时渲染和可恢复事实共存
agent/turn-stopping Turn 结束前的最后控制点,可注入新工作或改写结束理由
step/end vs turn/end step/end 表示单次请求结束,turn/end 表示整个任务边界关闭

6. 失败、重试与取消:不把任务留在"半完成"状态

前五节覆盖的是"一切顺利"的黄金路径。但生产系统的成熟度,取决于它在异常路径上的表现。

这一节研究三个问题:

  • 模型请求失败了,能不能重试?
  • 用户点了取消,正在执行的工具怎么处理?
  • 无论发生什么,如何保证日志里不会留下"半截"状态?

6.1 流式请求失败:agent/request-error 恢复钩子

当流式请求在网络层失败时(如限流、超时、服务暂时不可用),DSH 提供 agent/request-error waterfall 钩子,允许插件决定是否重试。

// 上游测试中验证的用法
ctx.on('agent/request-error', (error, next) => {
  if (error.code === 'RATE_LIMIT' || error.code === 'SERVICE_UNAVAILABLE') {
    return { kind: 'retry' }  // 同一 Step 内重新发起
  }
  throw error
})

关键边界:

agent/request-error agent/request
捕获什么 Adapter 发起的流式请求失败(网络层) 配置决策阶段
能否重试 ✅ 可以,同一 Step 内重新构建请求 ❌ 自身抛错不会被伪装成可重试的模型失败
典型场景 限流、超时、服务不可用 模型名称写错、配置不合法

agent/request middleware 自身抛出的错误,不会进入 agent/request-error 恢复路径。这是一个显式的责任边界:配置决策错误是开发态问题,不应该被伪装成运行时瞬态故障。

上游测试 tests/request-error.spec.ts 验证了这一行为:使用 MockAdapter 先制造 RATE_LIMITSERVICE_UNAVAILABLE,再返回成功响应——断言仍处于同一 Turn、同一 Step。

6.2 用户取消:停止接收新工作,但已开始的要收束

用户点击取消,或上层系统发送取消信号时,cancel() 执行三个动作:

  1. 清空 Inbox:所有 next-turnnext-step 中的消息被清除
  2. 发出 AbortSignal:正在进行的模型请求收到中断信号
  3. 等待已启动的工具结算:若工具正在执行,先等待其完成或超时,再补齐"已中止"结果

为什么不能直接终止一切?

因为工具执行可能带有副作用——写了文件、调用了外部 API、修改了数据库。如果直接杀掉进程,这些操作会处于"未知"状态:不知道成功还是失败,也不知道是否需要回滚。

DSH 的策略是:

cancel() 被调用
  → 停止接受新工作(清空 Inbox)
  → 对正在进行的模型请求发送 abort 信号
  → 对已启动的工具调用,等待它自然结算或超时
  → 为尚未调度的工具补齐"已中止"结果
  → 写入 turn/end { kind: 'aborted', reason }

这样做保证了日志里不会出现"缺失的 tool/result"。每个 tool/call 都有对应的 tool/result,无论它是成功、失败还是被中止。这为后续恢复提供了完整的事件序列。

6.3 finally 守住 turn/end

这是 DSH 最硬的一条工程规矩:turn() 函数把 turn/end 写在 finally 块里。

// 伪代码
async function turn() {
  try {
    await session.append('turn/start', { turn })
    
    const claimed = await inbox.claim()
    const decision = await preStep(claimed)
    if (decision === 'reject') {
      // 不产生 Step
    } else {
      await step()
    }
  } catch (error) {
    // 记录错误
  } finally {
    // ← 无论如何,turn/end 一定写入
    await session.append('turn/end', { 
      turn, 
      reason: getEndReason() 
    })
  }
}

finally 保证了以下所有路径都能写入 turn/end

结束原因 触发条件
completed 所有 Step 正常完成,无待办工作
blocked agent/pre-step 返回 reject
failed 模型请求失败且未重试,或工具执行异常
aborted 用户取消或上层发送取消信号

为什么必须这样?

因为下一次恢复时,系统需要知道"上一个 Turn 是否已经结束"。如果 turn/end 缺失(例如只写了 step/end),恢复逻辑会认为这个 Turn 还挂着,可能尝试重新执行已部分完成的工作,导致重复操作或状态错乱。

finally 不是为"看起来完整",而是为"可恢复"这一持久化事实提供边界线。

6.4 finally 写入失败时的降级策略

finally 中写入 turn/end 也可能失败——磁盘满了、网络断了、Session 不可用。

session.append('turn/end') 本身失败时,DSH 的降级策略是:

1. 尝试写入本地缓存(如果配置了本地缓存)
2. 如果本地缓存也失败,把 turn/end 的内容打印到 stderr
3. 返回一个非零退出码,让上层系统知道"任务结束边界未能可靠记录"

为什么 turn/end 写入失败时不直接崩溃?

因为 Agent 系统运行的底层环境可能已经受损(磁盘满、网络断),此时崩溃可能会导致更严重的问题——比如正在执行的工具无法正常收尾。DSH 的选择是:尽力写入,即使写入失败也继续执行收尾逻辑,同时通过退出码告知上层系统"边界未完整记录"。

这是工程上"最终一致性"和"尽力而为"的取舍:turn/end 的缺失意味着恢复时可能有歧义,但比进程崩溃导致完全不可控要好。

6.5 完整链路图(含异常路径)

reject

enter

成功

失败

retry

放弃

无 tool-call

有 tool-call

cancel 信号

followup 入队

唤醒 driver

写入 turn/start

pre-step 拦截

turn/end blocked

写入 step/start

构建请求

发起模型请求

处理响应

agent/request-error

turn/end failed

step/end → turn/end completed

工具执行

结果入队 next-step

step/end

清空 Inbox

等待工具结算

turn/end aborted

finally 确保写入

写入成功?

完成

写入缓存/stderr + 返回错误码

6.6 与第 4 节"请求冻结"的关联

第 4 节提到:请求被 deepFreeze() 冻结后才交给 Adapter。

这条设计在异常路径中体现得更清楚:如果请求是可变的,某个插件在 agent/request-error 重试时可能已经修改了请求对象,导致重试时的请求和原始请求不一致,排错时无法判断"是请求本身错了,还是某个插件改错了"。

冻结保证了:请求是可重现的。 无论重试多少次,最终发给 Adapter 的请求内容都是一致的。

本节小结

异常类型 处理机制 关键设计
流式请求失败 agent/request-error 钩子可返回 retry 同一 Step 内重试,不创建新 Step
用户取消 清空 Inbox + AbortSignal + 等待工具结算 不强制中断,补齐已中止结果
任何异常 finally 写入 turn/end 保证结束边界一定存在
finally 写入失败 写入缓存/stderr + 返回错误码 尽力记录,不完全崩溃

7. 这一篇真正要带走的结论

  1. Agent Loop 的基本单位是 Turn 与 Step,不是 while 循环。 这使任务、工具续跑和关闭边界可表达。
  2. Inbox 是可恢复调度状态。 followupsteerinject 的区别决定一条信息应在新 Turn 还是下一 Step 生效。
  3. pre-step 是模型前的权威裁决点。 它可以拒绝或重写输入,而不会把未进入模型的内容伪造为聊天历史。
  4. 请求必须可重建且不可被下游篡改。 header、context、确定工具顺序与冻结 request 共同构成请求边界。
  5. 工具结果不是附属输出,而是下一 Step 的待办输入。 这才让多步 Agent 成立。
  6. turn/end 是持久化一致性的封口。 无论成功、失败还是取消,Runtime 都要努力留下一个明确的结束理由。

8. 闭卷复述

  1. followupsteerinject 分别写入哪个 Inbox 队列?它们何时唤醒 driver?
  2. 一个 Turn 为什么允许 0 个 Step?reject 时日志中应有哪些事件,不应有哪些事件?
  3. agent/pre-stepagent/requestagent/turn-stopping 分别解决什么问题?
  4. 工具调用完成后,是什么机制让 Loop 继续进入下一次模型请求?
  5. 为什么 turn/end 放在 finally,而不是只在"模型给出最终回答"时写入?

能把这五题讲清楚,再手画一次"followup → Inbox → Turn → Step → tool/result → next-step → turn/end"链路,就已经掌握 DSH Runtime 的主心骨。

下一篇:为什么 Agent 不能只存聊天记录

本篇多次提到 Session Event Log,却没有展开它如何派生模型历史、支持重放、恢复、fork 与投影。下一篇会从一句强约束开始:

模型可见,即已记录。

到那时,我们会解释为什么 DSH 选择事件日志,而不是直接保存一个可变的 messages 数组。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐