Skip to content

05 · Agent 循环:turn / step 生命周期

这一章走进 dsh 的心脏:agent-loop 插件如何驱动一个 Agent。核心是一张时序图——turn(轮次)与 step(步)的边界,以及挂在边界上的那些可拦截事件。

5.1 先定义两个容易混的词

官方文档的定义非常精确:

  • step(步) = 一次模型请求 + 这次请求调用的所有工具。模型回复"我要调用三个工具",这三个工具全部执行完、结果全部喂回,才算一步走完。
  • turn(轮次) = 零到多个 step。从"有输入唤醒 agent"开始,到"没有任何待办"结束。

一个用户问题通常触发一个 turn;turn 内部因为工具调用会拆成多个 step。工具调用结束后模型还要再请求一次(拿工具结果做最终回答),所以"工具循环"本质上就是"一个 turn 里的多个 step"。

5.2 时序全景图

下面这张图是官方 agent-lifecycle 文档时序图的忠实简化版,建议打印出来贴在屏幕边。左列是持久事件(写入会话日志,见第 7 章),右列是实时事件(当次运行的协调接口)。

5.3 逐帧讲解

① 唤醒与认领。 agent.followup(message) 把消息放进 inbox(收件箱)并唤醒 driver。inbox 有两个目标:next-turn(下一轮次的普通消息)和 next-step(插队进当前轮次下一步的 steering)。driver 开始一个 turn 时"认领"(claim)下一步输入 + 一条排队消息。

agent/pre-step:模型看什么,这里说了算。 这是每个 step 的入口检查点,waterfall 模式。监听器可以:

  • next() 放行(可替换消息);
  • 返回 { kind: 'reject' } 拒绝——turn 不产生任何 step,但日志保留"有过这次尝试"的记录。

上下文压缩(compaction)、消息改写、内容安全策略,都挂在这里。

③ 装配提示词。 system-prompt/assemble 瀑布把各插件注册的提示词片段(persona、工具使用引导、时间上下文……)和工具 schema 组装成最终的 system 与 tools。

agent/request:换配置,不改内容。 这个瀑布的返回值是 LlmCallConfig(provider/model/temperature/maxTokens)。模型选择、成本策略、路由切换在这里做。注意它与 llm/stream 的分工:request 换配置,stream 换流;消息内容在这两个点都不能改(只能改配置),因为内容必须能从日志重建。

⑤ 流式输出落盘。 模型回复的每个 chunk 都作为 assistant/chunk 追加进会话日志——UI 的流式显示和日后的回放,数据源是同一个

⑥ 工具循环。 模型回复里的 tool-call 被按 executionMode 分类(parallel 并发 / exclusive 独占),依次走 tools/pre-executetools/executetools/post-execute,结果写 tool/result。下一章细讲。

⑦ step 的收尾与 turn 的收尾。 如果还有工具结果没消费(或来了新的 steering),driver 回到 ② 开始下一个 step。都清空了,走 agent/turn-stopping(serial 事件,最后一个检查点:监听器不满意可以 agent.steer() 再塞一步进去),然后 turn/end,状态回到 idle

5.4 事件清单:持久 vs 实时

这是理解 dsh 事件体系的分水岭,官方架构文档原话:

Session events are durable facts appended to the log. Agent events carry a live Agent. Use one when the fact must survive a reload.

类别事件特点谁消费
持久turn/start turn/end step/start step/end user/message assistant/message assistant/chunk tool/call tool/result追加进日志,重载后还在,可回放持久化、UI 回放、遥测、fork
实时(agent)agent/inbox/* agent/status agent/created agent/disposed携带活 Agent,进程活着才有队列与状态观察
实时(拦截)agent/pre-step agent/request agent/request-error agent/turn-stoppingwaterfall / serial,可干预策略、路由、压缩、审计

选择指南:事实必须活过重载 → 持久事件;观察/干预当下工作 → agent 事件。

5.5 Agent 接口:你能拿到的句柄

agent-loop 驱动的是实现了 Agent 接口的对象。它的公开方法就是"外部世界与一个 Agent 的全部交互面":

方法作用
agent.followup(message)排队一条普通消息并唤醒(新 turn)
agent.steer(message)向最近的 step 插入 steering
agent.inject(message)注入模型可见上下文,不唤醒
agent.send(message, target, wakeup)低层原语,指定 inbox 目标
agent.cancel(cause, opts?)中止当前 turn / 清空 inbox
agent.whenIdle()等待 agent 进入静默
agent.runMaintenance(task)空闲期跑一个非 turn 维护任务
agent.ctx该 agent 的作用域上下文(isolate 过的子上下文)
agent.session它的会话日志
agent.statusidle / running

创建与销毁走注册表(ctx.agents):

ts
const handle = await ctx.agents.create({
  sessionId: SessionId('sess-1'),
  meta: { cwd: process.cwd() },
  agentOptions: { provider: 'mock', model: 'mock-1' },
  setup(agentCtx) { /* 在发布前组合它的局部世界:注册 scoped 工具、监听器 */ },
})
handle.agent.followup(message)   // 驱动它
await handle.agent.whenIdle()    // 等它安静
handle.dispose()                 // 拆掉它

注意 setup 的契约:组合(composition),不驱动(driving)——它只负责在 agent 的局部上下文里注册东西,绝不往里塞消息。agent 发布后由 owner 驱动。这个"先完整组合、再对外发布"的顺序保证任何观察者都看不到一个配置了一半的 agent。

5.6 为什么这么设计:三个决策的动机

为什么 step 是"模型请求 + 工具"而不是"模型请求"? 因为计费、日志、取消都发生在请求边界上。工具执行夹在两个请求之间,把它们绑成一个 step,你就有了一个自然的记账与回放单位:一次 step = 一次模型调用 + 它的全部工具后果。

为什么 pre-step 是 waterfall 而不是配置项? 因为"模型该看什么"没有唯一正确答案:压缩策略、内容过滤、消息改写可能要叠加。瀑布链让多个策略按注册顺序协商,每个都能否决——这是配置项表达不了的。

为什么 turn-stopping 是 serial? 因为它是"最后一次发言机会",需要按顺序逐个问完(serial 依次执行、可 bail),而不是并发七嘴八舌。分发模式的选择本身就在表达设计意图——这是 dsh 事件体系里值得反复品味的细节。

5.7 本章小结与练习

一个 Agent 的运行时 = inbox(输入队列)+ driver(turn/step 状态机)+ 五个服务(llm/tools/systemPrompt/session/agents)+ 挂在生命周期上的事件。所有的"智能"都在模型那边,harness 提供的全部价值就是这套可观察、可拦截、可回放、可替换的状态机。

小练习:给时序图标注事件模式

回到 5.2 的时序图,给每个事件标注分发模式(emit / waterfall / serial / parallel)。提示:需要"否决或替换"的决策点是 waterfall,需要广播事实的是 emit,最后检查点是 serial。答案在 Demo 7,那里会实际监听这些事件。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写