跳到主要内容

Agent 主循环:一次对话如何跑完

30 秒导读: 这是 nanobot 的心脏。你对着 agent 说一句话,它可能要连着调好几次 LLM、跑好几轮工具,才憋出最终答复。本章逐行讲 pkg/agents/run.go 里那个 for 循环怎么把「问模型 → 模型说要调工具 → 调完把结果塞回去再问」这套动作转起来,以及每一轮的状态存在哪、失败了怎么退回、上下文太长了怎么压缩。

本章聚焦 pkg/agents 这一个包。它假设你已经读过 01-config-and-agents.md(知道 AgentConfig 长什么样),下游的工具/MCP 细节在 03-tools-and-mcp.md,多模型路由在 04-llm-dialects-and-sampling.md,hook 背后的「万物皆 MCP」设计在 05-reflexive-mcp-design.md


1. 先建立直觉:什么叫「一次对话跑完」

先说清楚一个容易被忽略的事实:用户说一句话,不等于 LLM 被调一次。

一个能用工具的 agent,典型的一轮是这样的:

你: 帮我看看今天 git 有没有没提交的改动
模型: (我需要调 shell 工具) → tool_call: run("git status")
工具: <一大段 git status 输出>
模型: (把输出读进去) → "你有 3 个文件改了没提交:..."

中间那次 run("git status") 是模型自己要求的。nanobot 得:把工具跑掉、拿到结果、再问一次模型,模型这次才给出给人看的答复。如果模型这轮又要调工具,就再来一遍。

所以「一次 Complete」= 一个循环,循环体是「调一次 LLM + 跑掉它这轮要的所有工具」,循环什么时候停?当某一轮 LLM 不再要求调任何工具时 —— 这个状态在代码里叫 Done

一句话直觉:把它想成一个「问答接力」——每轮问模型一次,模型如果甩来工具就替它跑完再接着问,直到模型不甩工具、直接答话为止。


2. 顶层全景:Complete 这一个循环

入口是 Agents.Complete(pkg/agents/run.go:384)。它做的事,拆成「循环前的准备」和「循环体」两块。

怎么读下面这张图: 从上往下是一次 Complete 调用的时间线;中间那个方框是会重复执行的循环体,右侧标了「回到循环顶」的就是多轮。

Complete(req) ┄┄ pkg/agents/run.go:384

├─ 读 session:是不是 chat 会话?(isChat) run.go:387-413
├─ 从 session 取上一轮 Execution(previousRun) run.go:418-423
├─ NewThread? → 把旧 Execution 归档、清空当前线程 run.go:425-428
├─ 注册 defer:本次失败就把 session 回滚到 fallBack run.go:430-434

▼ ┌────────────────── for { } ── run.go:437 ───────────────────┐
│ configHook 注入 nanobot.system/config 默认 hook,可改 agent │ run.go:438 / 524
│ run() populateRequest → 压缩? → hook → 调 LLM │ run.go:446 / 604
│ session.Set(previousExecutionKey, currentRun) 存盘 │ run.go:457
│ toolCalls() 遍历模型这轮要的工具,逐个调,写回 ToolOutputs │ run.go:461 / toolcall.go:15
│ │
│ currentRun.Done ? │ run.go:463
│ ├─ 是 → 存盘、组装 finalResponse、return ────────────────┼─▶ 出循环,返回
│ └─ 否 → previousRun = currentRun;新建空 currentRun ───────┼──▶ 回到 for 顶
└───────────────────────────────────────────────────────────────┘

部件一句话职责:

部件干什么位置
Complete主循环本体:管会话状态、跑多轮、判 Donerun.go:384
configHook每轮开头注入 nanobot.system/config 默认 hook,允许运行时改 agent/加 MCP serverrun.go:524
run单轮:拼请求 → 判是否压缩 → before hook → 调 LLM → after hookrun.go:604
populateRequest把上一轮的输出/工具结果拼进 input,注入 agent 的 instructions/model/schema/工具run.go:105
addToolsToolMappings 转成给模型看的 ToolUseDefinitionrun.go:41
handleUIAction处理 MCP-UI 动作:prompt 展开、直呼工具run.go:276
toolCalls遍历模型这轮的工具调用,逐个 invoke,把结果写回 ToolOutputstoolcall.go:15
compact上下文快满时把历史摘要成一段,继续对话compact.go:86

3. 会话状态:ExecutionPreviousExecutionKey

在钻循环之前,先搞懂每一轮的状态存在哪,否则后面看不懂「上一轮」「回滚」。

3.1 一轮 = 一个 Execution

每跑一轮,nanobot 用一个 Execution 结构装下这一轮的全部状态(pkg/types/execution.go:7):

字段装的是
Request本轮原始请求(未拼历史前)
PopulatedRequest拼好历史、注入 agent 配置后、真正发给 LLM 的请求
ResponseLLM 这轮的回复(可能含工具调用)
ToolOutputsmap[callID]ToolOutput,这轮每个工具调用的结果
ToolToMCPServer工具名 → 哪个 MCP server 的映射(含上一轮遗留的)
Done这轮之后要不要停
CompactedMessages被压缩归档掉的历史消息

3.2 存在 session 里,键叫 "thread"

只有当这是一个 chat 会话(isChat,即 context 里有 session,run.go:387)时,状态才需要跨调用保存 —— 因为下一句用户消息进来时,得能接上上一轮。

存取用一个 key:PreviousExecutionKey,值就是字符串 "thread"(pkg/types/execution.go:5)。如果请求指定了 ThreadName,key 会拼成 thread/<名字>(run.go:403-405),这样同一个 session 里可以并存多条对话线。

循环开始前,先从 session 把上一轮读出来当 previousRun(run.go:418-423):

// run.go:420 —— 从 session 按 "thread" 键取上一轮 Execution
if lookup := (types.Execution{}); session.Get(previousExecutionKey, &lookup) {
fallBack = &lookup // 同时记为回滚点
previousRun = &lookup
}

fallBack 是这一整次 Complete 出错时要退回的快照

3.3 NewThread:归档旧线程

如果请求带了 NewThread(用户点了「新对话」),就把当前 previousRun 搬到一个带时间戳的归档键下,再把 "thread" 清空(run.go:425-428):

// run.go:426 —— 旧线程存到 "thread/<RFC3339 时间戳>",当前线程清空
session.Set(previousExecutionKey+"/"+time.Now().Format(time.RFC3339), previousRun)
session.Set(previousExecutionKey, nil)

历史没丢(可回溯),但新对话从空上下文开始。

3.4 失败回滚:defer

关键的健壮性设计:在循环开始前注册一个 defer(run.go:430-434),只要这次 Complete 最终以 err != nil 返回,就把 session 的 "thread" 恢复成进来时的 fallBack:

// run.go:430 —— 命名返回值 err 非空时回滚会话状态
defer func() {
if err != nil && fallBack != nil {
session.Set(previousExecutionKey, fallBack)
}
}()

为什么要这样?因为循环体里每轮都会 session.Set(..., currentRun) 提前存盘(run.go:457),一旦中途炸了,session 里已经是半截的脏状态。这个 defer 保证:要么这次对话完整成功、状态往前推;要么彻底失败、状态回到调用前,不会留下一个「问了一半」的坏线程。(注意 err 是命名返回值,所以 defer 能读到最终错误。)


4. 循环体第一步:configHook 与「反身式」配置

循环每一轮的第一件事,是重新算一遍配置(run.go:438):

// run.go:438 —— 每轮开头都跑一次 config hook,得到本轮实际生效的 config
config, err := a.configHook(ctx, baseConfig, currentRun.Request.GetAgent())

configHook(run.go:524)干两件有意思的事:

  1. 强制注入一个默认 hook。 如果 agent 的 hooks 里还没有名为 config、目标是 nanobot.system/config 的 hook,就自动补一个(run.go:530-534)。也就是说,每个 agent 天生都挂着一个系统级 config hook,不用你手写。

  2. 让 hook 运行时改写 agent / 加 MCP server。mcp.InvokeHooks(run.go:535),传入 AgentConfigHook(带当前 agent、session 元信息、SessionID)。如果 hook 返回了新的 Agent,就克隆 baseConfig.Agents 后替换掉;返回了新 MCPServers,就并进去(run.go:544-568)。

为什么用「新 context」跑?—— run.go:444 注释点明:runCtx := types.WithConfig(ctx, config),用新 context 携带本轮 config,避免把值泄漏到外层。

这就是 nanobot「万物皆 MCP」哲学的体现:连「这轮 agent 用什么配置」都是一个可被 MCP hook 拦截、动态决定的事。展开讲见 05-reflexive-mcp-design.md;hook 类型定义见 pkg/types/hooks.go


5. 循环体第二步:run —— 单轮怎么调一次 LLM

run(run.go:604)是「一轮」的主体,顺序是:拼请求 → 判压缩 → before hook → (UI 动作?) → 调 LLM → after hook

5.1 populateRequest:把上一轮拼进这一轮

populateRequest(run.go:105)是循环能「接力」的关键。它把 previousRun 的产出接到本轮 input 前面。

它要解决的小问题: LLM 是无状态的,你每次都得把「到目前为止聊了啥」整个发过去。上一轮模型说的话、它调工具拿到的结果,都要拼回来。

拼接分两段(run.go:108-139):

  1. 上一轮的输出消息(InternalMessages + Output)逐条搬过来。但会跳过「悬空的工具调用」——如果某个 ToolCallpreviousRun.ToolOutputs 里找不到对应结果,就丢掉它(run.go:115-119),否则历史里会出现「有 tool_use 没 tool_result」的坏结构。

  2. 上一轮的工具结果:遍历 previousRun.ToolOutputs,把 Done 的那些结果 append 进去(run.go:130-135)。

// run.go:130 —— 按 callID 排序,把上一轮已完成的工具结果拼进 input
for _, callID := range slices.Sorted(maps.Keys(previousRun.ToolOutputs)) {
toolCall := previousRun.ToolOutputs[callID]
if toolCall.Done {
input = append(input, toolCall.Output)
}
}
input = append(input, req.Input...) // 本轮新消息接在最后

拼完历史,再把 agent 的定义注入请求——一堆「请求没显式给、就用 agent 默认」的填充(run.go:141-210):

注入项逻辑
SystemPrompt请求没给且 agent 有 Instructions → 解析动态指令run.go:160-166
Temperature / TopP请求为 nil 就用 agent 的run.go:168-174
MaxTokens / Truncation同上,零值兜底run.go:176-182
ToolChoice请求空就用 agent 的;若是接力轮(previousRun≠nil)则强制清空run.go:184-191
OutputSchema请求没给且 agent 定义了 Output → 转成结构化输出 schemarun.go:193-204
Model直接用 agent 的 model 覆盖run.go:210

注意 run.go:188-191 那条:接力轮里 ToolChoice 被强制清空——首轮可以逼模型「必须调工具」,但后续轮不该再逼,否则会死循环调工具。

动态 system prompt 的小花样(run.go:151-158):如果传进来的 SystemPrompt 本身是一段 JSON 且能解析成 DynamicInstructions,nanobot 会把它当成「指令来源」而非字面 prompt,清空后走 GetDynamicInstruction 重新取。

5.2 addTools:把工具翻译成模型能看懂的定义

addTools(run.go:41)负责把内部的 ToolMappings 变成发给 LLM 的工具清单 ToolUseDefinition

  • registry.BuildToolMappings 把 agent 引用的 tools / 子 agent / MCP server 全解析成映射(run.go:55)。子 agent 也能当工具用——这是 nanobot 做多 agent 的方式。
  • ToolIncludeContext 过滤(none 清空、thisServer 只留本 server 的,run.go:60-71)。
  • 逐个转成 ToolUseDefinition,每个工具的输入 schema 都过一遍 schema.ValidateAndFixToolSchema(run.go:82,函数在 pkg/schema/validation.go:11):
// run.go:80 —— 只挑「模型可见工具」,schema 修正后加入请求
if !types.IsModelTool(tool.Tool) { // types/meta.go:127
continue
}
req.Tools = append(req.Tools, types.ToolUseDefinition{
Name: key,
Parameters: schema.ValidateAndFixToolSchema(tool.InputSchema), // 补全/修正 JSON Schema
Description: tool.Description,
Attributes: agent.ToolExtensions[toolMapping.Target.Name],
})

为什么要 ValidateAndFixToolSchema?—— MCP server 给的 schema 五花八门,有的缺 type、有的字段不合规;不修正直接发给 OpenAI/Anthropic 会被拒。populateRequest 收尾时还会对全部 req.Tools 再统一 fix 一遍(run.go:217-221),双保险。

外部工具(opt.Tools)也在这里登记,标 External: true(run.go:88-100)——这个标记后面在 toolCalls 里决定「交回客户端执行」。

5.3 三类 hook 注入点

run 里前后夹着三种 hook,都走同一个 mcp.InvokeHooks(pkg/mcp/hooks.go:59),区别只是名字和载荷:

hook时机载荷类型短路能力位置
config每轮最开头(在 Complete 里,不在 run 里)AgentConfigHook改 agent / 加 serverrun.go:524
request(runBefore)调 LLM 前AgentRequestHook可直接返回 Response,跳过调 LLMrun.go:573
response(runAfter)调 LLM 后AgentResponseHook可改写 Responserun.go:589

runBefore 的短路很关键(run.go:644-651):如果 request hook 直接给了 Response,run 就不调 LLM 了,直接用这个响应——可以用来做缓存、拦截、mock。

5.4 handleUIAction:MCP-UI 动作

调 LLM 之前还插了一步 handleUIAction(run.go:276,在 run.go:655 被调)。它处理从 UI 传来的、不是普通聊天而是「结构化动作」的消息——nanobot 的 MCP-UI 扩展。

用户消息若是一段能解析成 UIAction 的 JSON,分两种处理:

  • Prompt 动作(replacePrompt,run.go:226):把 UI 里点选的 prompt 展开成真实消息。三种子情况——直接给了 Prompt 文本就替换;给了 RenderedMessages 就展开成多条消息;只给了 PromptName 就去 MCP server 取该 prompt 并回填 JSON 原消息缓存,免得每轮都重取(run.go:266-268 注释明说「故意改原始消息以便保存」)。
  • Tool 动作(run.go:347-379):UI 直接要求「调某个工具」。这时 nanobot 不调 LLM,而是伪造一个 assistant 响应,里面就一条 ToolCall,交给后面的 toolCalls 去执行——相当于用户替模型点了「调这个工具」。同时发 progress 通知让 UI 实时更新。

5.5 压缩挂载点

run 在拼完请求后、调 LLM 前,判断要不要压缩上下文(run.go:610-628),细节见 §7。


6. 循环体第三步:toolCalls —— 把模型要的工具跑掉

LLM 回来后,若它这轮要调工具,toolCalls(toolcall.go:15)负责执行。注意它不返回 error(run.go:461)——因为工具执行的任何问题都应该变成 tool_result 反馈给 LLM 让它自己处理,而不是中断整个循环。

6.1 主流程:遍历 Output 里的每个 ToolCall

// toolcall.go:75 —— 遍历模型这轮输出的每个 item,挑出工具调用
for _, output := range run.Response.Output.Items {
functionCall := output.ToolCall
if functionCall == nil || run.ToolOutputs[functionCall.CallID].Done {
continue // 不是工具调用,或已处理过,跳过
}
targetServer, ok := run.ToolToMCPServer[functionCall.Name] // 工具名 → MCP server
...
}

每个工具调用的处理分支:

  1. 映射不到 server(toolcall.go:83-137):工具名在 ToolToMCPServer 里找不到,就造一个错误 tool_result(「can not map tool X」)写回、发 progress 通知,并 return
  2. External 工具(toolcall.go:139-143):targetServer.Target.External 为真,说明这工具该由客户端执行 → 设 run.Done = true,把控制权交回客户端,循环就此结束等外部结果。
  3. 正常工具:调 a.invoke(toolcall.go:145)。

6.2 invoke:真正打到 MCP server

invoke(toolcall.go:205)把参数 JSON 反序列化,调 registry.Call 打到目标 MCP server(toolcall.go:215),把返回包成一条 role: "user"ToolCallResult 消息。工具/MCP 调用层的细节在 03-tools-and-mcp.md。

6.3 错误与取消

invoke 后要区分「工具报错」和「客户端主动取消」(toolcall.go:150-186):

// toolcall.go:150 —— 检查是不是客户端发起的取消
cancelCause := context.Cause(mcp.UserContext(ctx))
if err != nil || cancelCause != nil {
cancelErr, ok := errors.AsType[*mcp.RequestCancelledError](cancelCause)
if ok && cancelErr != nil {
err = cancelErr
run.Done = true // 取消:保留已有结果,停止后续工具,结束循环
} else {
err = fmt.Errorf("failed to invoke tool %s ...: %w", ...)
}
// 若工具没产出任何内容,兜底造一个含 err.Error() 的 tool_result
}
  • 普通错误 → 包成一条错误 tool_result 塞回历史,让 LLM 下一轮读到「这工具失败了」自己重试或换路。
  • RequestCancelledError(客户端点了停)→ 设 Done = true,保留已完成的部分,不再跑剩下的工具。

6.4 progress 与截断

  • progress 通知:有 ProgressToken 时,每个工具结果都发 notifications/progress(如 toolcall.go:99-114),UI 实时看到工具执行。
  • truncateToolResult(toolcall.go:188,实现在 truncate.go:34):工具结果超过 maxToolResultSize(50 KiB,truncate.go:18)就落盘到 sessions/<id>/truncated-outputs/,历史里只留一段截断内容 + 文件路径。防止一个巨大的工具输出把上下文撑爆。带 SkipTruncationMetaKey 的内容豁免(truncate.go:24-32)。

6.5 Done 的最终判定

toolCalls 收尾(toolcall.go:200-202):

if len(run.ToolOutputs) == 0 {
run.Done = true // 这轮一个工具都没调 → 模型是直接答话 → 停
}

这就是循环终止的核心判据: 模型这轮没要任何工具,ToolOutputs 空,Done 置真,循环退出返回。反之只要有工具被调,Done 保持 false,回到 for 顶再跑一轮(把刚拿到的工具结果拼进去)。

回到 Complete(run.go:463-486):Done 为真则组装 finalResponse(用 startID 定位起点,ConsolidateTools 合并工具消息)返回;否则 previousRun = currentRun,新建一个 currentRun(其 Requestreq.Reset()——清空 Input、清 NewThread 标记,pkg/types/completer.go:63),继续。


7. 精华:上下文压缩(compact)

这是本章最值得带走的一块。长对话迟早撑爆上下文窗口,nanobot 的解法是把老历史摘要成一段文字,替换掉原始消息。实现全在 pkg/agents/compact.go

7.1 何时压缩:shouldCompact

run 里、调 LLM 前判断(run.go:614)。逻辑(compact.go:31):估算当前请求的 token 数,超过窗口的 83.5% 就压缩:

// compact.go:31 —— 估算 token 超过阈值就触发
estimated := estimateTokens(req.Model, req.Input, req.SystemPrompt, req.Tools)
threshold := int(float64(contextWindowSize) * compactionThreshold) // 0.835
return estimated > threshold

窗口大小 getContextWindowSize(compact.go:22):agent 配了 ContextWindow 就用它,否则默认 200_000(compact.go:17)。token 估算在 pkg/agents/tokencount.go:34

7.2 怎么压缩:只摘「上次摘要之后」的部分

compact(compact.go:86)的巧妙点在于增量压缩,不是每次都摘整段历史:

  1. splitHistoryAndNewInput(compact.go:58)按「本轮新消息的第一个 ID」把 input 切成历史本轮新增——只压历史,新消息原样保留。
  2. 在历史里找最后一条压缩摘要(靠 meta key IsCompactionSummary,compact.go:43)。找到了,就只取它之后的消息 sinceLastSummary 来摘要,并把旧摘要文本当上下文喂进去(compact.go:94-106)。

这样每次要摘要的输入是有界的(只是上次摘要以来的增量),不会随对话越滚越大。

  1. 用不同 prompt 调一次 LLM 生成摘要:首次用 buildInitialCompactionPrompt,再压用 buildRecompactionPrompt(带合并规则:保留未决任务、折叠重复、标记已完成,compact.go:281)。两个 prompt 都要求输出固定模板(Goal / Key Instructions / What Happened / Current State / Next Steps / Open Questions)。

7.3 carry-forward:摘要怎么塞回去

摘要生成后,包成一条带 meta 标记的消息(compact.go:150-166),文本外面裹一层 carry-forward 提示(compact.go:331):

"The conversation history was compacted to stay within context limits. Continue the conversation naturally using the summary below as working context. Do not mention this compaction unless the user asks."

—— 告诉模型「历史被压缩了,拿这段摘要当工作上下文继续,别主动提这事」。

最终新 input = [摘要消息] + 本轮新消息(compact.go:169-170),原始历史被搬进 archivedMessages 存到 run.CompactedMessages(run.go:625),没删只是移出上下文。若某轮没压缩,则把上一轮的 CompactedMessages 原样带下去(run.go:630-632)。

压缩失败不致命:run.go:621-622 记个 error 日志后继续跑,顶多这轮上下文偏大。


8. 边界与坑

  • toolCalls 从不返回 error(run.go:461):设计如此——工具问题一律转成 tool_result 反馈给 LLM,不中断循环。想让循环停,靠 Done 或返回错误响应,不靠 panic。
  • 循环没有硬性最大轮数上限:终止完全靠「某轮不再产生工具调用」。理论上一个反复调工具的模型能转很多轮;实际由 token 增长 + 压缩 + 模型行为收敛来约束。
  • 悬空工具调用被静默丢弃(run.go:115-119):没有对应结果的 ToolCall 拼历史时被跳过,避免坏结构,但这意味着某些边界下模型的一次工具意图会「消失」。
  • 回滚只覆盖 chat 会话(run.go:418 起整块受 isChat 保护):非 chat 的一次性调用不走 session 存取,也就没有 defer 回滚。

9. 代码地图(导航索引)

主题文件符号
主循环入口pkg/agents/run.goAgents.Complete
会话状态结构pkg/types/execution.goExecutionPreviousExecutionKeyToolOutput
单轮执行pkg/agents/run.goAgents.run
拼历史 + 注入 agentpkg/agents/run.goAgents.populateRequest
工具定义翻译pkg/agents/run.goAgents.addToolstypes.IsModelTool
schema 修正pkg/schema/validation.goValidateAndFixToolSchema
config hookpkg/agents/run.goAgents.configHook
request/response hookpkg/agents/run.goAgents.runBeforeAgents.runAfter
hook 调用底座pkg/mcp/hooks.goInvokeHooks
MCP-UI 动作pkg/agents/run.goAgents.handleUIActionAgents.replacePrompt
工具执行循环pkg/agents/toolcall.goAgents.toolCallsAgents.invoke
结果截断落盘pkg/agents/truncate.gotruncateToolResult
上下文压缩pkg/agents/compact.goshouldCompactcompactgetContextWindowSizebuildCompactionCarryForwardMessage
token 估算pkg/agents/tokencount.goestimateTokens
请求重置pkg/types/completer.goCompletionRequest.Reset