Coding Agent Harness 拆解 · Agent 主循环:一个 loop 如何驱动一切(二)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent:codebot,以及它底下的执行内核 agentcore。本篇聚焦最核心的那个东西:Agent 主循环。我们先用几十行搭起最小心智模型(context→model→tools→result→next),再钻进 agentcore 真实的 runLoop,看清它的双层结构、callLLM 两阶段管线、流式启动工具、重试与溢出恢复、以及"谁能让循环停下来"。读完你应能回答:一个 coding agent 的循环凭什么每天都跑得稳,四条循环契约各自钉死了什么坑。
先建最小心智模型:Agent 就是一个循环
在钻源码之前,先把本质说清。你已经会调用大模型:把用户的话发过去,拿回一段文本,打印。这是"聊天",不是 Agent,它缺一样东西:行动能力。模型只能说,不能做,它读不了你的文件、跑不了你的命令、改不了你的代码。
Agent 补上的就是这一步:给模型一份"工具清单",告诉它可以调用 read、edit、bash,需要时不直接回答,改为提出一个工具调用。于是聊天变成了循环:
flowchart TB
A["准备上下文<br/>(历史消息 + 工具清单)"] --> B["请求模型"]
B --> C{"模型想调用工具吗?"}
C -->|"否,直接回答"| E["结束本轮"]
C -->|"是,发起 tool call"| D["执行工具"]
D --> F["把工具结果<br/>作为一条新消息追加"]
F --> B
它的五个动作是:准备上下文、请求模型、处理工具、写回结果、继续或停止。这就是 Agent Loop,是整个系统里最稳定的部分:模型会换、工具会换、UI 会换,但这个骨架几乎不变。举个例子,你说"把 main.go 里的 foo 改名成 bar",循环会这样转:
| 轮次 | 模型输出 | 循环做了什么 |
| --- | --- | --- |
| 第 1 轮 | 调用 read(main.go) | 执行读取,把文件内容作为工具结果追加 |
| 第 2 轮 | 调用 edit(main.go, foo→bar) | 执行编辑,把"改了几处"作为结果追加 |
| 第 3 轮 | 文本:"已改名,共 3 处" | 没有工具调用 → 循环停止,本轮结束 |
注意:模型从头到尾没碰过你的磁盘。它只是一轮轮地"提议",读写实际发生在本地工具里。这引出两条一旦抓稳就不会被绕晕的边界。
两条边界
边界一 · LLM 边界。 循环内部用一套结构化消息流转(用户消息、助手消息、工具结果、思考块、图片、token 用量……)。实际发给 provider 前,这些要转换成该 provider 能接受的格式;模型返回的流式片段,又要统一回内部事件。进入模型前有一次"翻译",离开模型后有一次"归一",这层翻译把 OpenAI、Anthropic、Gemini 的差异关在门内,循环本身不需要知道自己在跟谁说话。
边界二 · 副作用边界。 模型不能直接产生副作用(读写文件、执行命令),它只能提出 tool call。副作用实际发生在本地工具里:既然都收口在工具执行这一步,就能在这里插入参数校验、危险拦截、人工审批。
flowchart LR
M["模型:我想 bash(rm -rf ...)"] --> G{"审批门 ToolGate"}
G -->|"批准"| X["真正执行"]
G -->|"拒绝"| R["把'被拒绝'作为结果喂回"]
codebot 的权限模式(strict / balanced / accept-edits / trust)就挂在这条边界上,实现属于《权限与审批门》。心智模型到此为止:真实的循环要扛住流式并发、重试、上下文溢出、被打断,远比这张流程图复杂。下面进入 agentcore/loop.go。
两个入口,一条循环
内核对外给了两个函数入口,都返回一个 <-chan Event,都在一条独立 goroutine 里跑,最后都汇入同一个 runLoop。
// Entry 1: start with new prompts. Each prompt is emitted and committed
// into context, then the loop runs.
func AgentLoop(ctx, prompts []AgentMessage, agentCtx AgentContext, config LoopConfig) <-chan Event
// Entry 2: resume from existing context without adding messages.
// Contract: the last message must ConvertToLLM into a user or tool role.
func AgentLoopContinue(ctx, agentCtx AgentContext, config LoopConfig) <-chan Event
| | AgentLoop | AgentLoopContinue |
| --- | --- | --- |
| 用途 | 用户发来新输入,开一轮新对话 | 中断后恢复、steering 出队后重放、自动续跑 |
| 是否追加消息 | 是,prompts 逐条 commitMessage | 否,直接跑既有 Messages |
| 前置校验 | 无 | Messages 为空 → 立即 ErrNoMessages 收尾 |
两个入口的 goroutine 骨架几乎一样,值得记住的是它的兜底:
go func() {
defer close(ch)
defer recoverToEnd(sink) // runs before close: even on panic, emit one EventAgentEnd
...
sink.emit(Event{Type: EventAgentStart})
sink.emit(Event{Type: EventTurnStart})
runLoop(ctx, ¤tCtx, &newMessages, config, sink)
}()
recoverToEnd 把循环里的 panic 变成一个干净的 EventAgentEnd(EndReasonError),不让 channel 静悄悄关闭;订阅者(比如 Agent 把自己标记为 stopped)依赖"每条终止路径都有一个 EventAgentEnd"这个不变式。defer 的顺序保证 recoverToEnd 跑在 close(ch) 之前,那条终止事件一定投递得出去。
::: tip 一切"消息进上下文"都走 commitMessage
commitMessage 是唯一入口:它同时把消息追加进运行时上下文 currentCtx.Messages 和本次新增列表 newMessages,并触发 OnMessage 钩子(codebot 用它做会话落盘)。无论是初始 prompt、助手回复、工具结果、还是 steering 注入,全部经它入库;newMessages 最后会随 EventAgentEnd 一起带出去。
:::
四条循环契约(源码写死的)
runLoop 开头有一段注释,把整个循环最容易踩坑的四个不变式列成 "Core loop contracts"。它们不是注解,是这段代码之所以正确的地基:
- 流式的工具调用生命周期信号才是权威;stop reason 只是提示,不能作为工具状态的唯一来源。
- 工具结果只在请求它的那条助手消息之后才追加进上下文,哪怕执行是在流式过程中就已经启动了。
- 一个流式工具调用一旦完成,本轮就不再自动重试:重放可能重复副作用。
- steering 会阻止尚未启动的工具;已启动的工具按各自的
InterruptBehavior 决定继续还是取消。
这四条会在下面的双层循环、流式启动工具、重试逻辑里一一兑现。先记住它们,读循环体时就能对上号。
双层循环结构
runLoop 的主体是一个双层 for 循环,内外两层解决的是两类完全不同的"继续":
- 内层(
for hasMoreToolCalls || len(pendingMessages) > 0):一轮接一轮跑"模型调用 + 工具执行",只要还有工具要调、或还有待注入的 steering 消息,就继续转。
- 外层(
for {}):当内层认为"该停了",外层给应用最后的挽留机会:有 follow-up 消息就复活整个循环,或让 StopGuard 否决这次停止。
flowchart TB
Start([进入 runLoop]) --> Outer{{外层 for}}
Outer --> Inner{内层: hasMoreToolCalls<br/>或 pendingMessages?}
Inner -->|否| FollowUp{有 follow-up?}
Inner -->|是| Abort{ctx 已取消?}
Abort -->|是| EndAbort[EventAgentEnd<br/>EndReasonAborted]
Abort -->|否| Max{turnCount ≥ maxTurns?}
Max -->|是| EndMax[EventAgentEnd<br/>EndReasonMaxTurns]
Max -->|否| Inject[注入 pendingMessages<br/>EventTurnStart]
Inject --> LLM[callLLMWithRetry]
LLM --> Tools{有 toolCalls?}
Tools -->|是| Exec[执行工具 + commit 结果<br/>收集 steering]
Tools -->|否| Resp
Exec --> Resp[EventModelResponse<br/>turnCount++]
Resp --> StopTool{StopAfterTool 命中?}
StopTool -->|是, guard 放行| EndStop1[EventAgentEnd EndReasonStop]
StopTool -->|是, guard 否决| InjectBack[pendingMessages = inject]
StopTool -->|否| Length{length 截断需恢复?}
InjectBack --> Inner
Length -->|是| InjectBack
Length -->|否| Steer[取下一批 steering] --> Inner
FollowUp -->|有| Revive[pendingMessages = followUp] --> Outer
FollowUp -->|无| Guard{StopGuard 放行?}
Guard -->|注入| Revive
Guard -->|放行| EndStop2[EventAgentEnd EndReasonStop]
steering 与 follow-up 的分工,正好对应内外两层:
| | steering | follow-up |
| --- | --- | --- |
| 语义 | 运行中打断(Steer) | 跑完之后追加(FollowUp) |
| 生效位置 | 内层,下一次 LLM 调用前注入 | 外层,本该停止时复活循环 |
| 对在飞工具 | 停未启动的、按 InterruptBehavior 处理已启动的(契约 4) | 不影响 |
callLLM:两阶段管线
每一轮的 LLM 调用由 callLLM 负责。它不是简单地"把消息丢给模型"。这是一条固定顺序的管线,顺序本身为上下文正确性和 prompt 缓存命中服务:
sequenceDiagram
participant Runner as runLoop
participant CL as callLLM
participant CM as ContextManager
participant Conv as ConvertToLLM + Repair
participant Model as ChatModel
Runner->>CL: callLLMWithRetry(...)
Note over CL,CM: Stage 1 · 上下文投影
CL->>CM: Project(messages)
CM-->>CL: projection(可能 ShouldCommit)
Note over CL,Conv: Stage 2 · 转换 + 修复配对
CL->>Conv: RepairMessageSequence(ConvertToLLM(msgs))
CL->>CL: buildToolSpecs(tools)
CL->>CL: 前置 SystemBlocks + markLastMessageForCache
CL->>Model: GenerateStream(messages, tools, opts)
- Stage 1 —
ContextManager.Project:把完整消息历史投影成"这次实际要发给模型的视图"。若投影结果 ShouldCommit(发生了真实压缩),就通过 CommitContext 把新基线写回,同时替换运行时 agentCtx.Messages。何时压、压成什么是《上下文管理》的主题,循环这里只负责"在 LLM 调用前调用它、并按结果换基线"。
- Stage 2 —
RepairMessageSequence(ConvertToLLM(...)):先把应用层 AgentMessage 转成 LLM 级 Message(默认 DefaultConvertToLLM 会过滤纯 UI 消息),再修复 tool_call / tool_result 的配对:provider 对"有 tool_use 却没有对应 tool_result"之类的残缺序列会直接报错,这一步兜住它。
buildToolSpecs:把 Tool 接口转成发给模型的 ToolSpec。若工具集含 DeferFilter,未激活的延迟工具不发 schema、已激活的带 DeferLoading: true 发出去。
- 前置 system blocks +
markLastMessageForCache:把静态系统提示拼在最前并保持逐轮字节稳定,让 OpenAI 这类前缀缓存能命中 "system + tools" 前缀;当应用开启显式缓存编排(CacheLastMessage 非空),再在最后一条非 system 消息上打一个缓存写断点,从尾部往前扫、跳过每轮变化的 system reminder,让断点落在最新的 user 输入 / tool_result / 助手回合上。
最后 callLLM 组装 per-call 选项(ThinkingLevel、PromptCacheKey)并走流式。注释特意说明:流式初始化失败会直接冒泡成 error,没有静默回退到非流式 Generate:TUI 与事件订阅者都依赖流式事件做实时渲染和取消语义,偷偷换执行模型就是改契约。
流式中启动工具
callLLMStream 是 "provider 流 → loop 事件" 的转换器。这里补上循环最精妙的一点:工具不必等模型说完才开始执行,在流式过程中就已经启动了(契约 2)。
case StreamEventTextEnd, StreamEventThinkingEnd, StreamEventToolCallEnd:
partial = ev.Message
if ev.CompletedToolCall != nil {
info.HasCompletedToolCalls = true
if hooks.OnToolCallComplete != nil {
hooks.OnToolCallComplete(*ev.CompletedToolCall) // schedule the tool mid-stream
}
}
runLoop 传进来的 OnToolCallComplete 钩子,在第一个工具调用成型时懒创建一个 turnToolExecutor,之后每个成型的工具调用都 Add 进去。于是当流结束、callLLM 返回助手消息时,早成型的工具可能已经在跑了。回到 runLoop,它据此分两条路取结果:
if callInfo.HasCompletedToolCalls && streamedTools != nil {
turnToolResults, steering = streamedTools.Wait() // already started mid-stream: wait for it
} else {
turnToolResults, steering = executeToolCalls(...) // regular path: build the executor now
}
无论哪条路,工具结果都是在助手消息 commit 之后才逐条 commitMessage 进上下文的:这就是契约 2 落地的样子。turnToolExecutor(agentcore/turn_tool_executor.go)用 Add / Wait / AbortAndWait 统一了"全部加完再等"和"边流边加"两种模式,并用 MaxToolConcurrency 控制并发(安全工具可并行、不安全工具独占),执行器内部机制属于《工具系统》。
::: tip stop reason 只是提示(契约 1)
注意上面用的是 callInfo.HasCompletedToolCalls(来自流式的 CompletedToolCall 信号),不是助手消息的 StopReason。反过来,当模型报告 StopReasonLength(输出被 max_tokens 截断)且没有完成的工具调用时,循环会 stripToolCallBlocks 把那些参数很可能残缺的 tool_call 块剥掉,避免带着坏 JSON 去校验、触发 provider 拒绝。
:::
重试与上下文溢出恢复
callLLM 外面裹着 callLLMWithRetry,它把两类"可恢复失败"分开处理:
flowchart LR
Call[callLLM] --> Ok{成功?}
Ok -->|是| Ret[返回]
Ok -->|否| OF{上下文溢出?}
OF -->|是| Recover[recoverOverflow<br/>压缩后重试一次]
OF -->|否| Done{工具已完成<br/>且非幂等?}
Done -->|是| Bail[放弃重试 · 契约3]
Done -->|否| Cancel{被用户取消?}
Cancel -->|是| Bail2[终止 · 不重试]
Cancel -->|否| Retryable{可重试?}
Retryable -->|是| Reset[resetTurnState<br/>退避 → EventRetry → 再试]
Retryable -->|否| Fail[返回错误]
几个关键判断,都对应一条契约或一个真实故障模式:
- 上下文溢出走
recoverOverflow:调 ContextManager.RecoverOverflow 压缩历史、换成 recovery.View、必要时 CommitContext 落新基线,然后重试一次(不算普通重试次数)。没配 ContextManager 就直接返回溢出错误。
- 工具已完成且非幂等(
HasCompletedToolCalls && !ToolsAreIdempotent):直接放弃重试,这就是契约 3:重放会重复已经发生的副作用。只有应用声明 ToolsAreIdempotent 时,才会 resetTurnState() 把失败那次启动的在飞工具 AbortAndWait 掉,再干净地重试整轮。
- 用户取消(
context.Canceled)永不重试:下一次尝试只会再撞到 ctx.Done(),还会弹出让人困惑的 "retry (1/N)"。
PartialStreamError(流没发 done 就断了)当作可重试:多半是瞬时的网络/provider 流格式问题,重开一次请求就能恢复。
退避时长由 retryDelay 算:优先尊重限流错误里的 Retry-After,否则指数退避 1s, 2s, 4s, 8s… 封顶在 defaultMaxRetryDelay(60s)。每次重试前 emit 一个 EventRetry,codebot 的 Session 把它转成 UI 上的自动重试提示。
停止判定:谁能让循环停下
循环的终止不是单点,它由一组分级的出口构成。理解它们等于理解 EndReason 那几个取值从哪来:
| 出口 | 触发位置 | EndReason | 是否过 StopGuard |
| --- | --- | --- | --- |
| Abort(ctx.Err()) | 内层每次迭代开头 | aborted | 否(安全阀不可被否决) |
| 超过 maxTurns | 内层每次迭代开头 | max_turns | 否 |
| callLLM 不可重试错误 | 重试耗尽 | error | 否 |
| 模型报 StopReasonError/Aborted | 收到助手消息后 | error / aborted | 否 |
| StopAfterTool 命中 | 终端工具成功后 | stop | 是(StopTriggerAfterTool) |
| 无工具调用 + 无 follow-up | 内外层都跑到底 | stop | 是(StopTriggerEndTurn) |
设计点在于:StopGuard 是所有"正常停止"的唯一仲裁者(agentcore/stop_guard.go)。两条正常停止路径,自然收尾(end_turn)和终端工具早退(stop_after_tool),都要过它这一关:
inject, escalate := consultStopGuard(ctx, config, StopInfo{
TurnIndex: turnCount, Message: lastAssistantMsg, Trigger: StopTriggerEndTurn,
})
if escalate { /* EventAgentEnd EndReasonError */ }
if inject != "" { pendingMessages = []AgentMessage{UserMsg(inject)}; continue } // veto stop, keep going
而错误路径永不咨询 guard:Abort、provider 报错、maxTurns 安全阀都是直接终止;guard 不能凌驾于用户中断和安全上限之上。consultStopGuard 还有个安全默认:guard 不允许停止却又没给注入消息时,按"允许停止"处理,绝不静默卡死。StopAfterTool 让终端工具(比如"提交本章"这类一锤定音的动作)成功后直接结束循环、不浪费后续轮次,但它同样要过 guard,所以一次过早的终端退出仍可被否决。
Agent 如何驱动这台循环
codebot 并不直接调 AgentLoop,它通过 Agent(agentcore/agent.go)这层有状态包装,三步把上面的一切接起来:buildConfig 把 Agent 的字段翻译成一个 LoopConfig(Model / MaxTurns / ToolGate / ContextManager / StopGuard,并把 steering、follow-up 两个队列包成闭包、把落盘包成 OnMessage 和 CommitContext);startPromptRunLocked 构造 AgentContext、派生可取消的 runCtx、起 goroutine 消费循环;consumeLoop 读那条事件 channel 更新内部状态、同步分发给所有监听者。
config := a.buildConfig()
a.mu.Unlock()
go a.consumeLoop(AgentLoop(runCtx, msgs, agentCtx, config))
于是策略全在 LoopConfig 里注入、状态全在 Agent 侧累积,而 loop.go 那台纯循环对"谁在用它、给谁看"一无所知。codebot 的 Session 再 Subscribe 到 Agent 之上,把内核事件加工成产品世界。
三问回顾
- 这个机制解决了什么痛点? 用一台双层循环统一了 Agent 的全部推进逻辑:内层跑"模型 + 工具 + steering",外层负责"follow-up 复活"和 "StopGuard 仲裁",让"继续"与"停止"都有唯一、可测试的判定点;四条循环契约把流式并发工具、重试、副作用安全这些最容易错的地方钉死。
- 如果没有它,会在哪里崩? 若把 stop reason 当工具状态用,会漏跑或重复跑工具;若重试不看"工具是否已完成",会重复副作用;若停止判定散落各处,Abort、
maxTurns、guard 会互相打架,终端工具早退也无从否决。
- 它和别的部分怎么接上? 向上,循环只 emit 一条事件流,由
Agent / Session 消费;向下,它依赖注入进来的 ChatModel、ContextManager 和一组 Tool。循环把工具"调起来、接回来",把上下文"投影、压缩",把停止"仲裁掉",每一段都是另一篇的主题。
延伸阅读