从 Inbox 到 turn/end,沿 agent.ts 拆解 Turn、Step、流式输出、同 Step 重试和工具续行,看懂一次用户输入为什么可能触发多次模型请求。第一次看 Agent Loop,我下意识把“一条用户消息”理解成“一次模型请求”。顺着 packages/core/agent-loop/src/agent.ts 走完以后,才发现这两个边界并不重合。
用户消息先进入 Inbox。驱动器打开 Turn,再根据当时欠下的工作创建 Step。模型如果要求调用工具,工具结果写回 Session 后,同一个 Turn 会继续开下一个 Step;请求失败后选择重试,甚至不会增加 Step 编号。
这篇只做一件事:沿当前源码把一条输入从 Inbox 追到 turn/end。
项目地址:deepseek-ai/deepseek-harness
源码基线:0.1.0-rc.5
输入先进入 Inbox,不会直接调用模型
ReactLoopAgent 暴露了三种投递方式:
followup(input: UserMessage): void {
this.send(input, 'next-turn', true)
}
steer(input: UserMessage): void {
this.send(input, 'next-step', true)
}
inject(input: UserMessage): void {
this.send(input, 'next-step', false)
}
followup() 把消息放进 next-turn 队列,并唤醒驱动器;steer() 把消息放进 next-step 队列,驱动器正在运行时会并入当前 Turn;inject() 同样准备下一 Step 的输入,但不会单独把空闲 Agent 唤醒。
这三个方法的差异不是命名上的细节。它们决定一条消息应该开启新 Turn,还是在当前 Turn 的下一个模型边界生效。
Agent 从 idle 进入 running 后,只启动一条驱动链:
private async kick(): Promise<void> {
try {
while (await this.turn()) {}
} catch (_error) {
// 已上报的失败和取消在驱动器边界收口
} finally {
// 恢复 idle;如果还有待处理输入,再次唤醒
}
}
所以同一个 Agent 不会因为连续收到几条消息就并发跑几套主循环。Inbox 可以继续收消息,kick() 负责按 Turn 串行消化。
Turn 在认领输入之前就已经开始
turn() 先追加 turn/start,然后才尝试生成第一个 Step:
const turn = phase.turn + 1
this.session.append('turn/start', { turn })
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
while (true) {
const step = phase.step + 1
const decision = await this.preStep(target, { turn, step })
// ...
}
这个顺序意味着 Turn 记录的是一次被驱动器正式接手的尝试,而不只是“成功请求了模型”。后面的策略即使拒绝输入,turn/start 也已经是 Session 事实。
preStep() 做了三件事:从指定队列认领消息、重新组装 System Prompt 和工具 schema、进入 agent/pre-step waterfall。
const claimed = this.inbox.claim(target, position.turn)
const assembly = await this.loopCtx.systemPrompt.assemble(
assembleContextFor(this, signal),
)
const decision = await this.dispatch.waterfall(
'agent/pre-step',
{ messages: claimed, ...position, signal },
() => Promise.resolve({
kind: 'enter',
messages: context === undefined
? claimed
: [...claimed, context],
}),
)
这里的返回值是权威决定。监听器可以保留消息、替换消息,也可以直接返回 reject。压缩、Hook 注入和运行时上下文都能在这个位置影响下一步,但最终进入模型的用户消息还要先写入 Session。
有 Turn,不一定有 Step
源码专门处理了两种“零 Step”情况:
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
if (phase.step === 0 && decision.messages.length === 0) {
turnEnds = { kind: 'completed' }
return false
}
第一种是 agent/pre-step 明确拒绝。第二种是第一批输入在认领后被移除,或者监听器把 enter 改写成了空消息。
两种情况都不会追加 step/start,也不会调用模型,但 finally 仍会追加 turn/end。Session 中留下的是一个完整、可解释的 Turn,而不是让这次尝试看起来从未发生。
这也是 Turn 不能简单等同于“用户问了一次,模型答了一次”的原因。Turn 是驱动器的工作边界,Step 才是模型交互边界。
Step 先记输入,再从 Session 派生请求
排除前面的零 Step 分支后,代码才正式打开 Step。第一个 Step 需要非空 enter;工具欠下的后续 Step 即使没有新用户消息,也可以继续,这一点后面会看到。
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)
// ...
} finally {
this.session.append('step/end', { turn, step })
}
用户消息先成为 user/message 事件,随后 step() 才从 Session 推导模型历史:
const { request, preparedCall } = await this.buildRequest(
turn,
step,
assembly.tools,
system,
this.session.deriveMessages(),
signal,
)
Agent Loop 没有维护另一份私有聊天数组。恢复会话、分叉会话和重新构造请求都以 Session 日志为准。
buildRequest() 还会经过 agent/request,解析最终 Provider 和模型,并在配置首次出现或发生变化时写入请求头:
const header = canonicalHeader({
config,
...(system ? { system } : {}),
...(tools.length > 0 ? { tools } : {}),
})
if (!this.requestHeaderLogged) {
this.session.append('request/header', {
header,
reason: baseline === undefined ? 'initial' : 'resume',
})
}
Provider、模型和上下文窗口变化时还会追加 request/context。因此,模型看到的不只是对话消息可追溯,系统提示词、工具 schema 和模型路由也有对应的日志事实。
流式输出先写日志,失败重试仍属于同一 Step
模型流返回的每个 chunk 都会先写进 Session,再交给 BlockAssembler 拼成完整消息:
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request)
?? this.loopCtx.llm.stream(request)
for await (const chunk of stream) {
chunkSeqs.push(
this.session.append('assistant/chunk', {
turn,
step,
chunk,
}).seq,
)
assembler.push(chunk)
}
成功结束后,Loop 追加 assistant/message,并用 sourceEventSeqs 指向组成它的 chunk。UI 可以实时消费 chunk,重放时又能拿到归一化后的完整消息。
请求失败时,代码不会立刻关闭 Step,而是进入 agent/request-error:
if (finish.kind === 'error' || finish.kind === 'aborted') {
const action = await this.dispatch.waterfall(
'agent/request-error',
{
turn,
step,
provider: request.provider,
failure: finish.failure,
retryPolicy: preparedCall?.retryPolicy,
signal,
},
() => Promise.resolve(undefined),
)
if (action?.kind !== 'retry') {
throw new LlmError(
finish.failure.message,
finish.failure.code,
finish.failure,
)
}
continue
}
这个 continue 回到 step() 内部的请求循环,所以重试仍使用同一个 turn 和 step。只有重试成功、明确失败或被取消后,外层 finally 才追加 step/end。
因此更准确的说法是:Step 是一次持久化的模型交互槽位,正常情况下对应一次模型响应;适配器失败后的重试属于同一个 Step,而不是悄悄制造新 Step。
工具结果会让同一个 Turn 继续开 Step
完整 Assistant 消息写入后,Loop 检查其中的 tool call:
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
没有工具调用,Step 返回 completed。存在普通工具调用时,executeToolCalls() 追加 tool/call,经过 Tools 管线执行,再按模型给出的顺序追加 tool/result;如果工具没有声明结束 Turn,step() 返回 null。
null 表示“这一步完成了,但模型还欠一次请求”。外层 Turn 把目标改成 next-step,再次执行 preStep() 和 step()。
这里有个不太直观的细节:普通工具结果已经在 Session 中,所以下一个 Step 即使没有新的用户消息,也会照常调用模型。deriveMessages() 会把刚写入的 tool/result 带进历史。只有工具额外返回运行时上下文时,那些上下文才会被放入 next-step Inbox。
工具可以并发执行,但 tool/result 仍按模型原始调用顺序提交。第五篇会把这段调度单独展开。
Turn 只有在“不欠任何工作”时才结束
每个 Step 关闭后,Turn 会同时看两个条件:当前是否已经有结束原因,以及 next-step Inbox 是否为空。
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'
这里故意检查了两次。agent/turn-stopping 监听器执行期间仍可以调用 steer();一旦出现新输入,第二次检查就不会结束 Turn,而是继续创建下一 Step。
真正退出时,finally 无论成功、阻塞、错误还是取消,都会写入带原因的 turn/end。如果 Inbox 里还有 next-turn 消息,turn() 会换一个新的 AbortController、把 Step 计数归零,并让 kick() 打开下一个 Turn。
max-tokens 也是 Turn 级结果:某个 Step 命中上限后,即使后续因为 steering 又跑了 Step,最终结果也不会被普通 completed 覆盖。
把两条常见日志并排看,边界会更直观:
直接回答:
turn/start
step/start
user/message
request/header + request/context
assistant/chunk*
assistant/message
step/end
turn/end (completed)
调用工具后继续回答:
turn/start
step/start # Step 1
user/message
assistant/message # 含 tool-call
tool/call
tool/result
step/end
step/start # Step 2,同一个 Turn
assistant/message # 最终回答
step/end
turn/end (completed)
以后排查“为什么模型多请求了一次”,我会先看 Session 中有几个 step/start,再看前一步是否产生了 tool/result、steering 或额外上下文;排查“为什么没有调用模型”,则看 turn/start 后是否直接出现了 blocked 或零消息的 turn/end。
下一篇继续追 executeToolCalls():工具调用如何经过执行模式、审批、超时和结果后处理,又怎样在并发执行时保持日志顺序稳定。