DeepSeek Harness 一条用户任务是怎样跑起来的:从 `followup()` 到 `turn/end`
与第三篇的映射:Cordis 机制在本文中对应什么?
在进入正文之前,先回答一个读者很可能有的疑问:“第三篇讲的 Cordis 机制(Fiber、Effect、Event),在本文里到底对应什么?”
这是一个快照映射表,不是完整的对应关系,但能帮你把两篇文章的认知衔接起来:
| Cordis 概念 | 在本文中的体现 |
|---|---|
| Fiber(生命周期) | 每个 Turn 和 Step 的边界归属。turn/end 写在 finally 中、无论成功失败都写入,正是 Fiber 的 UNLOADING 状态在 Agent Loop 层面的投射。 |
| Effect(可逆副作用) | step/end 的边界清理、tool/call 和 tool/result 的配对写入、turn/end 作为所有副作用的封口。 |
| Event(控制流) | agent/pre-step、agent/request、agent/request-error、agent/turn-stopping 等控制点,都是 Event 机制在 Agent Loop 中的具体应用。 |
有了这个映射,你在阅读下面关于 Turn/Step 边界的讨论时,可以随时回看这张表,理解"为什么每一步都要写在 Session 里"——因为 Fiber 的生命周期边界就是通过 turn/start/turn/end 和 step/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 —— 持久化,可恢复,是"事实"
- 虚线箭头:控制流 —— 内存中生效,不持久化,是"决策"
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 循环被唤醒后:
- 先写
turn/start:不管后续能不能处理,先记录"有一个任务开始了"。这是为了审计和排错——即使最终被拒绝,日志里也有迹可循。 - 从 Inbox 领取消息:按照上一节的规则,取一个
next-turn消息(当前 Turn 的输入)。 - 组装 System Prompt:把各段提示词拼成完整的上下文。
- 进入
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
放行后,进入模型调用阶段:
- 进入
agent/request钩子:第二个控制点。插件可以在这里改写模型请求(比如换模型、调整参数),但最终必须提供合法的 provider 和 model。 - 调用 LLM Adapter:真正向大模型发起流式请求。
- 每个 chunk 都写日志:模型输出的每个片段,都会先写入
assistant/chunk,最后汇总成一条assistant/message。
关键设计:流式输出既让 UI 实时显示,又保证恢复时能重建完整消息。
第五阶段:工具调用(如果存在)
opt 有 tool-call
Agent → Tools: executeToolCalls()
Tools → Log: tool/call + tool/result
Tools → Inbox: additionalContexts → next-step
如果模型返回了工具调用:
- 执行工具:Tool Runtime 执行
get_weather之类的函数。 - 写工具事件:
tool/call(调用了什么)、tool/result(返回了什么)都写入日志。 - 写入 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/start、step/start、user/message、assistant/message、tool/call、tool/result、turn/end |
写入 Session Event Log |
| 实时控制 | agent/pre-step、agent/request、agent/turn-stopping、agent/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-turn、next-step、以及用于持久化操作的内部队列。
next-turn 和 next-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 followup、steer、inject 的写入语义
现在用两条队列重新理解这三个 API:
followup(message):把消息写入next-turn,调用wakeDriver()。当前 Turn 结束后,下一轮的第一个 Step 会领取这条消息作为新任务的输入。steer(message):把消息写入next-step,调用wakeDriver()。如果当前正在执行某个 Turn,消息会在当前 Step 结束后、下一步开始时被立即消费;如果没有正在执行的 Turn,消息会等待下一个 Turn 的第一个 Step 中被消费。inject(message):把消息写入next-step,但不调用wakeDriver()。消息会留在队列里,直到其他操作(比如followup或steer)唤醒 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() 是整条链路的"总指挥"。它负责:
- 打开一个 Turn(写入
turn/start) - 从 Inbox 领取工作(
next-turn+next-step) - 准备模型请求(组装 Prompt、运行时上下文)
- 交给插件拦截(
agent/pre-step) - 如果放行,创建 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)(放行并可选改写)
这个顺序意味着: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/startuser/message- 任何模型请求
日志中只包含:
turn/start
turn/end { kind: 'blocked' }
这个边界的意义在于:被拒绝的消息不会伪装成"模型已经看到的历史"。
如果先写了 user/message 再拦截,日志里就会有一条"用户说了什么"的记录,但模型实际上从未处理过它。回放会话时,这段历史会被错误地重建。pre-step 在 step/start 和 user/message 之前执行,从根本上避免了这个问题。
上游测试 tests/interception.spec.ts 验证了这一行为:使用 MockAdapter 时,pre-step reject 后请求数为 0,事件只有 turn/start 与 turn/end,结束理由为 blocked。
本节小结
| 关键点 | 说明 |
|---|---|
turn/start 最先写入 |
即使最终被拒绝,日志链路也完整 |
preStep() 先组装 Prompt,再执行拦截 |
拦截器看到的是已组装的候选,不是原始消息 |
reject 不产生 Step |
无 step/start、无 user/message、无模型请求 |
| 日志干净 | 被拒绝的 Turn 只有 turn/start → turn/end(blocked) |
4. Step 的创建、请求构建与拦截
上一节结束在:agent/pre-step 返回 enter,Turn 被放行。
现在进入真正执行模型请求的阶段——Step 的创建与执行。
4.1 pre-step 放行后发生了什么?
pre-step 返回 enter 之后,Agent Loop 按顺序执行三个动作:
- 写入
step/start:记录"一次模型请求开始了"。这是一个持久事实,用于恢复和审计。 - 写入
user/message:把进入 Step 的用户消息逐条写入 Session。这一步在pre-step放行之后才执行——被拒绝的消息不会出现在日志里,不会伪装成"模型已经看到的历史"。 - 调用
step():进入真正的模型请求执行流程。
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——包含 provider、model、reasoningEffort、maxTokens 等配置参数。
插件可以做两件事:
- 替换模型:比如把
deepseek-v4-flash换成deepseek-v4-pro - 调整参数:比如把
reasoningEffort从medium调到high
但它最终必须保证 provider 和 model 是合法的。它不能返回一条"聊天消息"作为响应——它的返回值必须是 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/request 和 agent/pre-step 的区别:
agent/pre-step |
agent/request |
|
|---|---|---|
| 作用范围 | 决定 Turn 是否进入、改写输入消息 | 决定请求用哪个模型、什么参数 |
| 输入 | 候选消息 + 运行时上下文 | LlmCallConfig(provider/model/参数) |
| 输出 | reject 或 enter(messages) |
修改后的 LlmCallConfig |
| 调用时机 | Step 创建之前 | Step 创建之后、模型请求之前 |
4.4 请求为什么要冻结?
buildRequest() 最终以 deepFreeze() 构造 request,再附上 Session id 和 AbortSignal。
这不是防御式编程的装饰,而是在划定一条责任线:
离开 Loop 交给 Adapter 的请求,不能再被下游异步代码悄悄修改。
如果请求对象是可变的,下游代码(比如某个工具插件、某个拦截器)可能在不经意间修改 model 或 tools 字段,导致日志里记录的请求和实际发出的请求不一致。冻结之后,任何试图修改的行为都会直接报错,保证了请求的完整性和可审计性。
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/message 在 pre-step 放行后写入 |
被拒绝的消息不会污染会话历史 |
buildRequest 四件事 |
读取提案 → agent/request 改写 → prepareCall 解析 → deepFreeze 冻结 |
agent/request 是配置决策点 |
改模型/参数,不改消息内容 |
| 请求冻结 | 防止下游异步篡改,保证日志与实际一致 |
| 请求头写入 Session | 每一步的模型配置可追溯、可恢复 |
5. 模型流式响应、工具调用与下一步的触发
上一节结束在:请求被构建、冻结,交给 LLM Adapter 发起流式请求。
现在看模型返回后发生了什么。
5.1 两种情况:直接回答 vs 工具调用
模型返回的内容分两种:
- 没有 tool-call block:模型直接输出最终答案
- 有 tool-call block:模型要求调用工具
5.2 情况一:没有 tool-call,直接回答
模型输出最终答案时,处理流程如下:
- 流式写入 chunk:每个
assistant/chunk实时写入 Session,支持 UI 流式渲染 - 汇总为完整消息:
BlockAssembler将所有 chunk 汇总为一条assistant/message - Step 结束:写入
step/end - 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。
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 会:
- 立即写入
assistant/chunk事件(支持 UI 流式渲染) - 交给
BlockAssembler汇总 - 流结束后,汇总为一条
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/end 和 turn/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/start → step/end 重复出现,但 turn/start 和 turn/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_LIMIT 与 SERVICE_UNAVAILABLE,再返回成功响应——断言仍处于同一 Turn、同一 Step。
6.2 用户取消:停止接收新工作,但已开始的要收束
用户点击取消,或上层系统发送取消信号时,cancel() 执行三个动作:
- 清空 Inbox:所有
next-turn和next-step中的消息被清除 - 发出 AbortSignal:正在进行的模型请求收到中断信号
- 等待已启动的工具结算:若工具正在执行,先等待其完成或超时,再补齐"已中止"结果
为什么不能直接终止一切?
因为工具执行可能带有副作用——写了文件、调用了外部 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 完整链路图(含异常路径)
6.6 与第 4 节"请求冻结"的关联
第 4 节提到:请求被 deepFreeze() 冻结后才交给 Adapter。
这条设计在异常路径中体现得更清楚:如果请求是可变的,某个插件在 agent/request-error 重试时可能已经修改了请求对象,导致重试时的请求和原始请求不一致,排错时无法判断"是请求本身错了,还是某个插件改错了"。
冻结保证了:请求是可重现的。 无论重试多少次,最终发给 Adapter 的请求内容都是一致的。
本节小结
| 异常类型 | 处理机制 | 关键设计 |
|---|---|---|
| 流式请求失败 | agent/request-error 钩子可返回 retry |
同一 Step 内重试,不创建新 Step |
| 用户取消 | 清空 Inbox + AbortSignal + 等待工具结算 | 不强制中断,补齐已中止结果 |
| 任何异常 | finally 写入 turn/end |
保证结束边界一定存在 |
finally 写入失败 |
写入缓存/stderr + 返回错误码 | 尽力记录,不完全崩溃 |
7. 这一篇真正要带走的结论
- Agent Loop 的基本单位是 Turn 与 Step,不是 while 循环。 这使任务、工具续跑和关闭边界可表达。
- Inbox 是可恢复调度状态。
followup、steer、inject的区别决定一条信息应在新 Turn 还是下一 Step 生效。 pre-step是模型前的权威裁决点。 它可以拒绝或重写输入,而不会把未进入模型的内容伪造为聊天历史。- 请求必须可重建且不可被下游篡改。 header、context、确定工具顺序与冻结 request 共同构成请求边界。
- 工具结果不是附属输出,而是下一 Step 的待办输入。 这才让多步 Agent 成立。
turn/end是持久化一致性的封口。 无论成功、失败还是取消,Runtime 都要努力留下一个明确的结束理由。
8. 闭卷复述
followup、steer、inject分别写入哪个 Inbox 队列?它们何时唤醒 driver?- 一个 Turn 为什么允许 0 个 Step?
reject时日志中应有哪些事件,不应有哪些事件? agent/pre-step、agent/request、agent/turn-stopping分别解决什么问题?- 工具调用完成后,是什么机制让 Loop 继续进入下一次模型请求?
- 为什么
turn/end放在finally,而不是只在"模型给出最终回答"时写入?
能把这五题讲清楚,再手画一次"followup → Inbox → Turn → Step → tool/result → next-step → turn/end"链路,就已经掌握 DSH Runtime 的主心骨。
下一篇:为什么 Agent 不能只存聊天记录
本篇多次提到 Session Event Log,却没有展开它如何派生模型历史、支持重放、恢复、fork 与投影。下一篇会从一句强约束开始:
模型可见,即已记录。
到那时,我们会解释为什么 DSH 选择事件日志,而不是直接保存一个可变的 messages 数组。
更多推荐
所有评论(0)