agent-loop:一个轮次的完整旅程
专栏:DeepSeek Harness · 第 7 / 14 篇现在读整个产品的心脏:packages/core/agent-loop。前几篇的积累在这里汇聚——事件日志(第 6 篇)是它写的,Cordis 事件(第 4 篇)是它的神经,提示词(第 8 篇)是它组装的,工具(第 9 篇)是它调度的。
两个名词,先立规矩
- step(步骤)= 一次模型请求 + 它调用的全部工具执行。
- turn(轮次)= 零或多个 step:在领取首条输入之前打开,在不再欠任何工作时关闭。
一个被拒绝的轮次可以没有任何 step(日志仍记录了这次尝试)。轮次结束原因有六种:completed、aborted、blocked(pre-step 拒绝)、error、max-tokens、interrupted(崩溃恢复时合成)。
主循环骨架
循环的相位是一个简单的三态机:
// packages/core/agent-loop/src/agent.ts:39-47
type Phase =
| { kind: 'idle'; lastTurn: number }
| {
kind: 'maintenance'
abort: AbortController
lastTurn: number
wakeRequested: boolean
}
| { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean }
三种输入入口是理解循环的第一把钥匙(agent.ts:125-152):
followup(input) // → next-turn 队列 + 唤醒:开新轮次
steer(input) // → next-step 队列 + 唤醒:注入当前轮次的下一步
inject(input) // → next-step 队列、不唤醒:静静躺着,等下次有活时捎上
cancel(cause) // // 清队列(可保队列只中止活动)+ abort 当前轮次
next-step 里的东西(steering 消息、注入上下文、工具结果的附加上下文)会在当前轮次的下一步被领走;next-turn 则开启新一轮。输入先持久化(agent/inbox/spliced 事件)再改内存——和 Session 一致的「先记录后行动」。
turn():轮次的骨架
turn() 是主循环,值得分段全读。前半——打开轮次、pre-step 决策、进入步骤:
// packages/core/agent-loop/src/agent.ts:266-305(节选)
this.session.append('turn/start', { turn })
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
try {
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
// 首步批次被改写为空:仍关闭一个无 step 的轮次——
// 「花掉轮次边界,但不花模型调用」
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
signal.throwIfAborted()
this.session.append('step/start', { turn, step })
phase.step = step
try {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
const stepEnd = await this.step(decision.assembly, decision.startsRequestSeries === true)
// max-tokens 是粘性的:一旦某步触顶,后续正常完成的
// 步骤不能把轮次结局降级回 completed
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
} finally {
this.session.append('step/end', { turn, step })
}
后半——收尾检查点与轮次关闭:
// packages/core/agent-loop/src/agent.ts:306-342(节选)
signal.throwIfAborted()
// 轮次将关且没有新的 next-step 输入时,串行征询监听器
if (turnEnds && this.inbox.nextStep.length === 0) {
await this.dispatch.serial('agent/turn-stopping', { turn, signal })
}
if (turnEnds && this.inbox.nextStep.length === 0) break
target = 'next-step' // 还有活:下一步从 next-step 领取
}
} catch (error: unknown) {
if (signal.aborted) {
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
throw error
}
// 每个失败都是结构化的
turnEnds = {
kind: 'error',
error: error instanceof LlmError
? error.failure
: { message: errorChain(error), code: 'UNKNOWN' },
}
this.throwError(error)
} finally {
// 无论哪条路径退出,turn/end 必写
this.session.append('turn/end', { turn, reason: turnEnds! })
}
if (!this.inbox.hasPending) return false
phase.abort = new AbortController() // 换新控制器:旧控制器上的唤醒闩锁全部失效
phase.wakeRequested = false
phase.step = 0
return true // 队列还有活:驱动器开下一轮
三个设计点值得停下来品:
turn-stopping是 serial 事件且没有next()——监听器无法「否决」关闭轮次,只能往 inbox 里steer()一条新输入,然后循环重读 inbox 依数据决策:有新输入就再跑一步,没有就关。把控制权反转成数据,监听器注册顺序就无法影响结局。finally里写turn/end——错误、取消、正常结束,轮次边界在日志里永远闭合。- max-tokens 粘性——某个步骤触顶后,轮次结局不可能被后续步骤洗白。
pre-step:模型看到什么,在这里决定
// packages/core/agent-loop/src/agent.ts:237-255
private async preStep(target: InboxTarget, position: { turn: number; step: number }): Promise<PreparedStep> {
if (this.phase.kind !== 'running') throw new Error(`agent "${this.id}": pre-step outside running phase`)
const signal = this.phase.abort.signal
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context],
}),
)
signal.throwIfAborted()
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
默认行为:领走全部 next-step 批次(轮次边界上再搭一条 next-turn),把变化的运行时上下文快照并进批次。然后 agent/pre-step waterfall——监听器可以改写批次、附加内容,或直接 reject(该轮以 blocked 关闭,已领取的消息保持已删除)。
step():发请求、流式、settlement、工具回流
// packages/core/agent-loop/src/agent.ts:344-382(前半,节选)
private async step(assembly: PromptAssembly, startsRequestSeries: boolean): Promise<StepEndReason | null> {
const { turn, step, abort: { signal } } = this.phase
const system = renderPrompt(assembly)
while (true) { // 这个 while 只为 request-error 重试服务
const surfaceGeneration = this.session.surface.replaceGeneration
const { request, preparedCall } = await this.buildRequest(
turn, step, assembly.tools, system,
this.session.deriveMessages(), // ← 消息历史:日志的纯函数
startsRequestSeries, surfaceGeneration, signal,
)
const live = new AssistantStreamAttempt(...)
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
live.start()
for await (const chunk of stream) {
signal.throwIfAborted()
live.push(chunk)
}
buildRequest 里值得注意:agent/request waterfall 允许插件替换调用配置(换模型、调参数),但消息不可触碰——它们是日志的投影;请求信封以四种 reason 落成 request/header(initial / resume / change / series)。成功 settle 之后(agent.ts:444-476):
// packages/core/agent-loop/src/agent.ts:450-476(节选)
const message = createAssistantMessage({ content: live.blocks(), source: {...} })
live.settle('assistant/message', () => this.session.append('assistant/message', {
turn, step, message,
...live.usage === undefined ? {} : { usage: live.usage },
stream: live.stream,
}, { surfaceOp: 'append' }).seq)
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
const { concluded } = await executeToolCalls(
this.loopCtx, turn, step, toolCalls, signal,
context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
)
return concluded ? { kind: 'completed' } : null // null = 还有欠的活,继续下一步
工具结果的 additionalContexts 被 splice 进 next-step 队列——工具回流驱动下一步的闭环就在这一行。失败路径则把流提交为 assistant/attempt,再走 agent/request-error waterfall:监听器(如 llm-retry 插件)返回 {kind:'retry'} 则 continue 重开一步,否则抛出结构化的 LlmError。
全景时序图
先看循环的相位状态机(注意 running 期间 turn/step 两个计数器单调递增):
再把整条链画出来(这是本专栏最重要的一张图,建议对照源码走一遍):
与其他模块的联系
- session(第 6 篇):循环是日志唯一的写入者;
deriveMessages()是每次请求的消息来源;turn/end 在 finally 里必写; - system-prompt(第 8 篇):pre-step 调
assemble()拿 system 文本与工具 schema; - tools(第 9 篇):
executeToolCalls把工具结果经additionalContextssplice 回 inbox next-step——工具回流驱动下一步; - llm(第 11 篇):
preparedCall.stream()发起采样;失败经agent/request-error征询重试。
两个最容易混淆的事件对:agent/assistant-stream 是进程本地的实时流(start/chunk/end 三帧,chunk 是瞬态,给 UI 用的);assistant/message 是持久 settlement(嵌完整流,模型历史的权威)。同理 tools/result(实时观测)vs tool/result(持久事件)。一对「实时广播 + 持久结算」贯穿 dsh 的所有数据面。
下一篇拆开循环里的两大部件之一:提示词组装。