逐行拆解
turn()方法:从"一次模型调用"到"一个完整的 Agent 回合"
01 一个问题:为什么大多数 AI 只会"接一句"?
你用过很多 AI 助手,有没有发现一个现象:
你问一句,它答一句。然后停了。
哪怕它的回答里写了"让我查一下资料",实际上也不会真的去查。哪怕它说"我需要分几步完成",实际上也不会自动继续下一步。
为什么?
因为调用一次大模型 API,和让一个 Agent 自主运转,是完全不同的两件事。
前者是"函数调用",后者是"状态机管理"。
DeepSeek Harness 的 turn() 方法,就是解决这个问题的核心答案。今天,我们逐行拆解这段不到 100 行的代码,看看它如何把"一次模型调用"变成"一个完整的 Agent 回合"。
02 先看骨架:一个 Turn,里面套着多个 Step
private async turn(): Promise<boolean> {
// 回合开始
const turn = phase.turn + 1
this.session.append('turn/start', { turn })
// 核心:步骤循环
while (true) {
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
// ...
const stepEnd = await this.step(decision.assembly)
// ...
if (turnEnds && inboxEmpty) break
target = 'next-step'
}
// 回合结束
this.session.append('turn/end', { turn, reason: turnEnds })
return hasMoreWork
}这是整段代码最震撼的设计:
一个 Turn 不是一次模型调用,而是一个
while (true)循环。
什么意思?
普通框架的逻辑是:
用户提问 → 调用模型 → 返回答案 → 结束Harness 的逻辑是:
Turn 开始
Step 1: 思考/行动
Step 2: 发现 inbox 有新消息,继续
Step 3: 工具返回结果,继续
Step 4: 模型说"我做完了"
Turn 结束(或进入下一个 Turn)while (true) 这一行,就是 Agent 从"被动应答"走向"主动运转"的分水岭。
03 拆解三步:决策、执行、收敛
第一步:preStep —— 不是想跑就能跑
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}在真正调用模型之前,Harness 先问一层"决策层":
• 当前 Agent 有没有权限执行? • 消息队列里有没有有效输入? • 安全策略是否允许继续?
这相当于给模型加了一道**"闸门"**。如果决策层说"不",整个 Turn 直接标记为 blocked,不会浪费一次模型调用。
这是生产环境的关键设计。 实验室里的 Demo 可以跳过这一步,但线上系统必须有。
第二步:step —— 真正调用模型
for (const message of decision.messages) {
this.session.append('user/message', message)
}
const stepEnd = await this.step(decision.assembly)这一步才是我们熟悉的"调大模型"。但 Harness 做了两件事,让它不再是简单的 API 调用:
第一,消息被写入了 Session。
不是调完就丢,而是每一条消息都通过 session.append 记录下来。这意味着整个 Agent 的运行过程是完全可观测、可回放、可审计的。
第二,返回值 stepEnd 不是字符串,而是状态。
模型这次输出是正常完成?还是触发了 max-tokens?还是报错了?Harness 用结构化状态记录,而不是靠解析文本猜。
第三步:收敛判断 —— 什么时候停下来?
if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
if (turnEnds && this.inbox.nextStep.length === 0) break
}这段代码是整个循环的"刹车系统"。
停下来的条件有两个:
1. 当前 Step 给出了结束信号( turnEnds有值)2. 消息队列为空(没有新的外部输入需要处理)
但注意,这里触发了一个事件 agent/turn-stopping。这意味着**"准备停车"是一个可被拦截的过程**——其他模块可以监听这个事件,做收尾工作,甚至阻止停车。
这不是"一刀切"的结束,而是**"优雅收敛"**。
04 两个魔鬼细节:Sticky 和 Abort
细节一:Sticky max-tokens
if (turnEnds === null || turnEnds.kind !== 'max-tokens') {
turnEnds = stepEnd
}这段代码很短,但极其深刻。
它的意思是:一旦某个 Step 触发了 max-tokens(输出被截断),整个 Turn 的最终状态就必须保持为 max-tokens,后续正常完成的 Step 不能把它"覆盖"掉。
为什么?
因为 max-tokens 不是普通的结束,它意味着模型还没说完。如果你让后面的 Step 把它覆盖成 completed,你就丢失了一个关键信号:这个 Turn 的输出是不完整的。
这就是"粘性状态"设计。 它体现了 Harness 对 LLM 行为边界的深刻理解。
细节二:AbortSignal 贯穿始终
const { signal } = phase.abort
signal.throwIfAborted()
// ... 循环中多次检查 ...
if (signal.aborted) {
turnEnds = { kind: 'aborted', reason: signal.reason }
throw error
}整个 Turn 从始至终都绑定了一个 AbortSignal。
这意味着:外部可以在任何时刻优雅地取消这个 Turn——不是暴力杀进程,而是让循环自然走到一个安全点,记录 aborted 状态,然后干净退出。
对于需要长时间运行的 Agent 来说,这是生死攸关的设计。
05 错误处理:不是 try-catch,是结构化归因
turnEnds = {
kind: 'error',
error: error instanceof LlmError
? error.failure
: { message: errorChain(error), code: 'UNKNOWN' },
}
this.throwError(error)Harness 不把错误当成"异常"来恐慌,而是当成**"状态"来记录**。
• 如果是 LlmError(模型侧错误),保留原始结构,方便上游重试或降级。• 如果是未知异常,统一包装为 UNKNOWN,附带完整的错误链。
在生产环境里,"知道为什么失败"和"成功"一样重要。
06 收尾:一次状态重置,准备下一次奔跑
if (!this.inbox.hasPending) return false
phase.abort = new AbortController()
phase.wakeRequested = false
phase.step = 0
return true如果 inbox 没有待处理消息,返回 false——告诉调度器:"我干完了,别叫我了。"
如果还有消息,则重置状态:
• 新建一个 AbortController(旧的 signal 已失效)• 清除唤醒标志 • Step 计数归零
然后返回 true——"请继续调度我。"
这不是结束,是下一次开始的准备。
07 总结:这段代码到底做对了什么?
turn() | |
|---|---|
08 写在最后
很多人以为,Agent 的核心是"模型能力"——参数够不够大,上下文够不够长。
但看完这段代码你会发现:
真正让 Agent 从"玩具"变成"生产工具"的,不是模型,而是运行时。
while (true) 这一行,写起来只要几秒,但它背后是对状态机、可观测性、容错、并发控制的深刻理解。
DeepSeek Harness 的 turn() 方法,就是 Agent 运行时的教科书级实现。
下次当你写 Agent 框架时,不妨问问自己:
我的 Agent,有真正的"回合"概念吗?能在一次任务里连续思考多步吗?出错了能优雅降级吗?能被随时安全取消吗?
如果答案都是"是",恭喜你,你的 Agent 真正"活"过来了。
本文基于 DeepSeek Harness 开源代码分析,如有理解偏差,欢迎指正。
夜雨聆风