Skip to content
My Blog
Go back

模型看到的必须在日志里:dsh 的 Turn 执行流

上一篇讲了 dsh 的插件架构。这篇看它的心脏:一条用户输入进来之后,「模型思考 → 调工具 → 再思考」的循环怎么被驱动,以及每一步为什么都要留痕。

事件有两个平面

刚读代码时我踩过一个坑:turn/startagent/pre-step 这些名字看起来是同一类事件,其实分属两个平面。

平面例子写入方式语义
会话日志事件turn/start、step/start、tool/call、step/end、turn/endsession.append()已发生的事,持久、只追加
Cordis 分发事件agent/pre-step、agent/turn-stopping、tools/executedispatch.waterfall / serial将发生的事,可被拦截、改写

日志事件是「发生了什么」,分发事件是「要发生了,谁想拦一下」。前者写在 SessionEventMap(merge 可扩展,定义在 packages/core/session/src/types.ts),后者带 @mode 标注,是事件的公共契约的一部分。

session log 是唯一事实来源

dsh-session 提供的 Session 就是一条只追加的类型化事件日志。关键点:LLM 的消息历史不单独存,而是从日志派生出来的(deriveMessages())。packages/core/session/README.md 的说法是「the single source of truth every model-visible fact flows through」。

配套的几个细节都透着同一种脾气:

一个 turn 的完整旅程

turn/start ──(log)
  认领输入 → 组装 system prompt + 工具 schema(每步一次)
  agent/pre-step ──(waterfall,可拒绝/改写)
    step/start ──(log)
      LLM 请求(请求体深冻结,不再改写)
        tool/call ──(log)
        tools/pre-execute → tools/execute → tools/post-execute(皆 waterfall)
        tools/result ──(emit)
    step/end ──(log)
    ↺ 有需要就再来一步
  agent/turn-stopping ──(serial)
turn/end ──(log)

所有分发点都在 packages/core/agent-loop/ 里,它是「the harness’s only concrete loop」。比如 agent.tssession.append('turn/start', { turn })dispatch.waterfall('agent/pre-step', ...)dispatch.serial('agent/turn-stopping', ...) 各自就一行,管线骨架一目了然。

waterfall:能拦、能改,但必须调 next()

agent/pre-step 是最典型的拦截点。监听器拿到消息和一个 next(),返回一个决策:

// packages/core/agent/src/runtime-types.ts
export type PreStepDecision =
  | { kind: 'reject' }
  | { kind: 'enter'; messages: UserMessage[]; startsRequestSeries?: true }

返回 reject,这一步被否掉,整个 turn 以 { kind: 'blocked' } 收尾;返回 enter 并换上新的 messages,等于改写进入模型的内容;调 next() 则委托给链的下一层,最内层的默认行为由循环自己提供。

看一个真实插件的用法,packages/context/time-context/prepend: true 挂在 agent/pre-step 上:先 await next() 拿到下游决策,如果是 reject 或者已中止就原样放行,否则往 messages 里追加一条自己生成的时间上下文消息再返回。

仓库规约对 waterfall 有条硬规则:「Waterfall listeners MUST call next() to delegate; returning without it short-circuits the chain.」不调 next 就是短路——拒绝和改写都要做得明明白白,测试里三种情况(透传、直接拒绝、直接替换)各有用例。

Model-visible ⟺ logged

把这些机制串起来的是根目录 AGENTS.md 里的一条不变量:

Model-visible ⟺ logged: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.

凡是会进入模型请求的内容,必须能从 session log 重建;想引入一种新的模型可见输入,先加一种 session 事件。上面 time-context 的例子正是这条规则的实例:插件往 messages 里塞了东西,就必须作为日志事件落盘。

packages/llm/llm/README.md 还补了后半句:「loop-built requests are deep-frozen and never rewritten」——循环组装好的请求深冻结,谁也不许事后改写。

这条不变量换来的东西很实在:回放、审计、测试全都可以只拿一条日志做文章。后面讲质量门禁那篇里「无 API key 的录制回放测试」,地基就是这一条。

下一篇讲 dsh 的另一个结构概念——capability seam,shell、文件系统、沙箱这些危险能力是怎么被做成可插拔的。

本系列


Share this post:

Previous Post
逐文件 100% 覆盖:dsh 的质量门禁长什么样
Next Post
第 06 课 · 持久层、投影与「日志化状态」