点击上方 前端Q,关注公众号
回复加群,加入前端Q技术交流群
你在 dsh 里敲一句"把这个仓库的主要包梳理一下",回车。
屏幕开始滚字,中间调了几次工具,最后停下。
这中间到底跑了什么?大多数 Agent 框架的答案是"一个 while 循环加几次 function call"。DeepSeek Harness 的答案要精确得多:一个 turn,里面若干个 step,每一步都往一本只能追加的日志上写字。
先把 Turn 和 Step 分清楚
官方的定义很短,但很关键。
Step 是"一次模型调用,加上它请求的那些工具执行"(step/start 的原话:one model call plus the tool executions it requested)。
Turn 封装的是一次模型循环的执行,不是整个会话日志。
翻成人话:
所以平时说的"这次任务跑了 8 轮工具",在 dsh 里是一个 turn、八个 step。
有个细节值得单独拎出来:turn 可以一个 step 都没有。
turn/start 是在 loop 认领排队输入、跑 pre-step 之前就开的。如果这次提议被拒绝、输入是空的、或者中途被取消,这个 turn 就以"没有 step"的形态关掉——turn/end 照样会写进日志。
这不是设计疏漏,是故意的:一次被拦下来的尝试,也是需要被记住的事实。
六个包串成一条脊柱
packages/core 里有六个包,一次 turn 会从头到尾穿过它们:
session/ | SessionEvent 日志,唯一真相源(ctx.sessions) |
system-prompt/ | ctx.systemPrompt) |
tools/ | ctx.tools) |
agent/ | Agentagent/* 事件词汇(ctx.agents) |
agent-loop/ | ctx.agentLoop) |
scope/ |
scope/ 是这六个里唯一不是服务的包——一个零依赖的小库(createScope / scopeOf / scopeTarget),位置压在 session/ 和 system-prompt/ 底下,就是为了让它们能用又不成环。
这里有一条我觉得最能体现设计品味的纪律:扩展插件依赖 agent,永远不直接依赖 agent-loop。
哪怕你需要拿到发起这次任务的那个 Agent,也走 agent 包。为什么?因为 agent-loop 只是"官方默认那台驱动器",插件不碰它,它才换得掉。
一次 turn 的完整站点
把官方描述的主线摊平,大致是这样:
→ 在 session log 上开 turn(turn/start)
→ agent/pre-step 决定进不进这一步
→ step/start
→ system-prompt 拼前缀 + 从日志 derive 出历史
→ agent/request 冻结这次调用配置
→ 经 LLM seam 流式拿回复
→ 经 ctx.tools 派发工具调用
→ 把所有"模型能看见的事实"写回日志
→ 下一个 step 从新日志重新 derive
注意最后两行。下一步的输入不是内存里攒的数组,是从日志重新推出来的。 这条约束贯穿整个运行时:模型看得见的东西,必须先落日志。
agent/pre-step 是请求推导之前唯一的一条 waterfall 链。它返回的是一个 PreStepDecision:
| { kind: 'reject' }
| {
kind: 'enter'
messages: UserMessage[]
startsRequestSeries?: true
}
reject 就是这一步不开。有意思的是被拒绝之后那条消息的下场——官方在 agent/inbox/claimed 里写得很死:它既不会被丢弃,也不会被重新变成 user/message,turn 直接无 step 关闭。
也就是说,它已经离开了 inbox,但没进入模型视野。你想做"敏感词拦截"这类插件,得自己想清楚要不要给用户一个交代。
agent/request 这条 waterfall 也有条硬规矩:它不能改消息。 它只能换那份冻结的调用配置(provider、model、reasoningEffort、maxTokens)。想让模型多看见一句话?走日志通道,别在这里塞。
三个投递口,脾气完全不一样
Agent 的 inbox 是两条有序的待处理队列:nextTurn 和 nextStep。
往里投东西的底层方法只有一个 send(message, target, wakeup),另外三个是固定预设的别名。它们的差别,全在"投到哪条队列"和"叫不叫醒驱动器":
followup():排一个普通的后续 turn,叫醒驱动器。这条消息会独占它自己那个 turn。steer():投给最近的那个 step 边界。驱动器空闲就直接开一个 turn,正在跑就在下个 step 边界消费掉。inject():给下一次 pre-step 塞模型能看见的上下文,不叫醒驱动器。inject() 那个"不叫醒"是最容易踩的。官方明说了:驱动器空闲时,注入的内容就一直挂着,直到有 follow-up 或 steering 把它唤醒;而且它可能错过一个 pre-step 已经认领完批次的请求。
文件变更通知、子目录 AGENTS.md、Skill 内容、定时提醒,走的都是这条。所以你看到"我明明注入了,模型怎么没反应",先看是不是没人叫醒它。
顺带说一句状态:AgentStatus 只有 idle 和 running 两个值。销毁不是第三种状态——它是把 Agent 从注册表里移走,发 agent/disposed。
turn 是怎么结束的
agent/turn-stopping 是 turn 关闭前的检查点,模式是 serial,没有 next()。
它的语义设计我很喜欢。一个监听器如果不同意关,不是靠"返回 false",而是靠 agent.steer(...) 真的投一条东西进去,然后机器重新读 inbox:有新的 steering 就再跑一个 step,没有就关。
官方一句话点破:Data decides,所以监听器顺序改变不了结果。
反向也一样。想提前结束一个工具循环?不是调什么 stop(),而是让工具结果带上 concludesTurn。还是数据说了算。
关掉的时候要写 TurnEndReason,一共六种:
completed | |
aborted | |
blocked | |
error | LlmFailure |
max-tokens | |
interrupted |
后两个值得说。
max-tokens 的规则是一票否决:只要有任意一个 step 撞了输出天花板,哪怕后面有插件让 turn 继续跑完了,整个 turn 也记 max-tokens 而不是 completed。"被截断过"这个事实优先级更高,消费方才分得清"干净停下"和"话没说完"。
interrupted 是唯一一个 loop 永远不会实时发的原因。它由崩溃恢复合成——进程挂了,日志里最后一个 turn 没关,resume 的时候给它补一个封口。崩之前记下来的事件,一条不动。
优劣势
优势:一次任务的边界是有数据结构的。
Turn、step、pre-step 决定、结束原因,全是日志里的一等公民。这意味着你能事后回答"第 3 个 step 为什么没调工具""这个 turn 是被取消还是被截断"——不靠猜,不靠 print。
对比一下那种"一个 while 循环 + 一堆回调"的实现:出了问题只能加日志重跑。
劣势:概念密度是真高。
光是 Agent 这一个接口就有 send / followup / steer / inject / cancel / whenIdle / runMaintenance,每个都有自己的唤醒语义和取消语义。agent/pre-step 是 waterfall、agent/turn-stopping 是 serial、agent/status 是 emit——模式记错就出事。
还有一层:agent/created 是 serial 且被 await 的,一个监听器抛异常会让整个创建失败。文档还专门警告,这些监听器不能 await agent.whenIdle(),否则直接死锁。
预览阶段这些签名还会变,我写这篇时依据的是官方 docs/subsystems/core.md 和 session.md。
我的判断
我最认同的一点,是它把"驱动器"和"Agent 契约"彻底分开了。
agent-loop 只是公开 Agent 契约的一个具体实现,官方自己都说它是"harness 的默认产品循环"。插件依赖接口和事件,不依赖这台驱动器。所以理论上你可以整台换掉——换成规划式的、换成图执行的——外面那圈插件一行不用改。
很多框架嘴上说"loop 可替换",实际上工具、记忆、UI 全在偷偷 import 那个 loop。DeepSeek 的做法是在包依赖层面把这条路堵死。
如果你只准备记一件事,记这个:先分清 turn 和 step,再看每个扩展点挂在哪一层。 这两个词搞混,后面所有事件、所有日志、所有取消语义都会跟着乱。
▎可进一步阅读
往期推荐