跳到主要内容

核心主循环:一次 query 如何端到端跑完

30 秒导读: Dexter 的心脏是 Agent.run —— 一个 async generator。它把「系统提示 + 历史 + 你这句话」拼成一个消息数组,然后进一个 while 循环:每一圈把整个数组喂给大模型,流式接住回答;如果模型要求调工具,就执行工具、把结果追加回数组、再转下一圈;如果模型直接给了文字、没要工具,那就是终答,循环结束。整个过程不返回一个大对象,而是一路 yield 事件(在思考、在调工具、工具好了、压缩了、完成了……),驱动方边收边渲染 UI。

本章是全书主线、必读的第一章。读完你应该能对着源码讲清楚:一次提问从组装消息、到反复调模型、到最终吐出答案,中间每一步代码在干嘛

上下文压缩的内部算法留给 上下文工程;工具的并发与权限闸门留给 工具层;本章只把它们当"主循环里的一个环节"点到。


1. 先建直觉:agent loop 到底是什么

1.1 一句话定义

agent loop = 让大模型「边想边动手」的那个反复循环。 模型本身只会输出文字;要它真正查资料、读文件、算数,就得给它一组工具,然后不断地:问模型 → 模型说"帮我调这个工具" → 程序真去调 → 把结果告诉模型 → 再问模型……直到模型说"我查够了,答案是……"。

Dexter 是一个金融研究 agent(可以理解为"金融版的 Claude Code")。你在终端里问它"给我分析下英伟达最近的财报",它不会一次答完,而是自己去调"搜索""读网页""查财务数据"等工具,来回好几轮,最后综合成一段回答。这一章讲的就是驱动这来回好几轮的那台发动机

1.2 用起来什么样

最小的调用长这样(真实调用见 src/cli.ts:468):

// 示意,非源码:一次问答的骨架
const agent = await Agent.create({ model: 'gpt-5.6-sol' }); // 装配工具 + 系统提示
for await (const event of agent.run('英伟达最近财报怎么样?')) {
// event 是一个个"进度事件":thinking / tool_start / tool_end / done ...
render(event); // 边收边画到屏幕上
}

关键就两点:Agent.run(...) 不是返回一个答案字符串,而是返回一个异步事件流;你用 for await 一个个把事件收下来,done 事件里才带着最终答案。

1.3 一句话类比

把主循环想成一个不知疲倦的研究助理在便签本上工作:

  • 便签本 = 那个不断增长的 messages 数组(系统提示、你的问题、模型每轮的思考、每次工具的结果,全按顺序贴上去)。
  • 助理每次抬头看一遍整本便签,然后要么说"我还得去查 X"(工具调用),要么说"好了,结论是……"(终答)。
  • 便签贴太满了(超上下文窗口),就有人来做摘要、撕掉旧页(压缩/截断),但结论那几页留着。

记住这个便签本模型,后面所有代码都是在维护这本便签、和决定"抬头看完之后干嘛"。


2. 顶层全景:一圈循环怎么转

2.1 参与的部件

部件干什么在哪个文件
Agent.run主循环本体,async generator,yield 事件src/agent/agent.ts:128
RunContext一次 run 的可变状态(迭代数、计时、token 计数、scratchpad)src/agent/run-context.ts:22
callModelWithStreaming / streamAndAccumulate流式调模型、把 chunk 累积成完整消息src/agent/agent.ts:299 :317
callModelWithMessages流式失败时的阻塞兜底调用src/agent/agent.ts:366
hasToolCalls判定「要调工具」还是「直接终答」src/utils/ai-message.ts:24
executeToolsAndCollectMessages执行工具、收集 ToolMessage(细节见第 02 章)src/agent/agent.ts:385
AgentEvent 联合类型对外播报的所有事件形状src/agent/types.ts:294
AgentRunnerController驱动方:消费事件流、驱动审批/提问、渲染src/controllers/agent-runner.ts:160
getChatModel / streamLlmWithMessages模型抽象:按 provider 造 LangChain client、调用、重试src/model/llm.ts:155 :359
resolveProvider / PROVIDERS按模型名前缀路由到 provider(OpenAI/Anthropic/…)src/providers.ts:99 :21

2.2 主线走一遍(先不进代码)

你的问题


[组装消息] SystemMessage + 历史 + HumanMessage(query)


┌───────────────── while (iteration < maxIterations) ─────────────────┐
│ │
│ ① 每轮开场维护便签:microcompact 轻量修剪 + 剥离旧的 thinking │
│ ② 流式调模型 callModelWithStreaming ──(失败)──▶ 阻塞兜底调用 │
│ └ 一路 yield stream_progress(requesting/thinking/…) │
│ ③ hasToolCalls(response) ? │
│ ├─ 否 ▶ handleDirectResponse ▶ yield done ▶【循环结束】 │
│ └─ 是 ▶ 把 AIMessage 贴进便签 │
│ ④ 执行工具 → 收集 ToolMessage → 贴进便签 │
│ ⑤ 大结果落盘/预算裁剪/超阈值压缩(→03) │
│ ⑥ drain 消息队列(你中途又发了新消息就并进来) │
│ │
└──────────────────────── 回到 ① 进下一圈 ───────────────────────────┘

▼ (跑满 maxIterations 也会退出)
yield done { answer, toolCalls, iterations, totalTime, tokenUsage }

一句话:「组装一次,循环多次;每圈要么长出一次工具调用、要么收尾终答」。 下面把每个环节拆开讲。


3. 循环入口:消息怎么组装、循环怎么起

3.1 一次 run 的状态:RunContext

进循环之前,run 先建一个 RunContext 装本轮的全部可变状态(src/agent/run-context.ts:22,由 createRunContext(query) 造):

字段作用
iteration当前第几圈,循环条件和 done 事件都用它
scratchpad便签之外的"账本":记录每次工具调用、思考、工具用量,done 时汇总成 toolCalls
tokenCounter累加每次 API 报告的 token 用量,算总量和 tokens/秒(src/agent/token-counter.ts:6)
startTime计时起点,done 里算 totalTime
lastApiInputTokens最近一次 API 真实报告的输入 token 数——比字符估算准得多,压缩阈值判断优先用它

注意 messages 数组RunContext 里——它是 run 里的一个局部变量,因为它每轮都被重赋值(压缩会整个替换掉它)。

3.2 初始消息数组

// src/agent/agent.ts:140-145
const historyMessages = inMemoryHistory?.getRecentTurnsAsMessages() ?? [];
let messages: BaseMessage[] = [
new SystemMessage(this.systemPrompt),
...historyMessages,
new HumanMessage(query),
];

三段拼一起,顺序固定:系统提示 → 历史几轮 → 你这次的问题systemPromptAgent.create 阶段就装配好了(见 §7);historyMessages 来自跨轮的内存历史,让你能追问"那再对比下 AMD"而不用重述上文。

3.3 主循环的条件与开场

// src/agent/agent.ts:149
while (ctx.iteration < this.maxIterations) {
ctx.iteration++;
// 每轮开场先维护便签……

maxIterations 默认 10(src/agent/agent.ts:24 DEFAULT_MAX_ITERATIONS)——这是硬护栏:模型来回调工具十圈还没收尾,循环就强制退出并给一句"达到最大迭代数"的 done(src/agent/agent.ts:280),避免无限烧钱。

每圈进模型前先做两件轻量的便签维护(细节属 03,这里点到):

  1. microcompact(src/agent/agent.ts:153):按需清掉老 ToolMessage 的正文,yield 一个 microcompact 事件。
  2. stripOldThinking(messages, 2)(src/agent/agent.ts:160):把 AIMessage 里的思考正文清空、只保留最近 2 条——但保留 tool_calls 结构(src/agent/agent.ts:496),因为 ToolMessage 必须能按 tool_call_id 配对到它的 AIMessage,拆了配对就报错。省 token,又不破坏消息配对关系。

4. 调模型:流式累积 + 阻塞兜底

4.1 为什么要流式,又为什么要兜底

流式是为了体验:模型一个字一个字往外蹦时,UI 就能实时显示"正在思考 / 正在回答 / 正在拼工具参数",而不是干等十几秒后一次性弹出。

兜底是为了健壮:有些 provider/配置不支持 streaming。所以外层 callModelWithStreaming 用一个 try/catch 兜住——流式一抛错,就退回阻塞式 callModelWithMessages 拿完整结果:

// src/agent/agent.ts:299-308
private async *callModelWithStreaming(messages) {
try {
return yield* this.streamAndAccumulate(messages); // 首选:流式
} catch {
return await this.callModelWithMessages(messages); // 兜底:阻塞
}
}

yield* 是关键:它把内层 generator 的所有 stream_progress 事件透传给最外层的 run,再透传给驱动方——事件流一竿子到底。

4.2 流式累积:一堆碎片拼成一条完整消息

模型流式返回的是一串 AIMessageChunk(碎片)。streamAndAccumulate 的活儿是把碎片用 .concat() 累加成一条完整的 AIMessage——尤其重要的是,工具调用的参数 JSON 是分很多片段流过来的,必须累加齐了才是完整的 tool_calls:

// src/agent/agent.ts:322-334(节选)
let accumulated: AIMessageChunk | null = null;
for await (const chunk of streamLlmWithMessages(messages, { model, tools, signal })) {
accumulated = accumulated ? accumulated.concat(chunk) : chunk; // 碎片累加
const { charDelta, mode } = inspectChunkContent(chunk); // 这片是啥内容
if (charDelta > 0 || mode !== 'responding') {
yield { type: 'stream_progress', charDelta, mode }; // 播报进度
}
}

流完之后,用累积结果造一条正式 AIMessage(带上 tool_callsusage_metadata 等,src/agent/agent.ts:340);如果里面有工具调用,再补 yield 一个 mode: 'tool-use' 事件,示意"要开始执行工具了"(src/agent/agent.ts:348)。

4.3 inspectChunkContent:从碎片形状推断"模型现在在干嘛"

驱动方想显示"思考中 / 回答中 / 在拼工具参数",但 chunk 自己不会说自己是什么模式——得看内容形状去推断。这就是 inspectChunkContent(src/agent/agent.ts:674)干的事。

LangChain 的 content 有两种形态:多数 provider 是纯字符串;Anthropic 是分类型的数组(text / thinking / tool_use 分块)。函数分别处理:

chunk 内容类型推断出的 mode含义
纯字符串responding在输出正文回答
textresponding同上
thinking / redacted_thinkingthinking在思考(推理)
tool_use / input_json_deltatool-input在逐字拼工具调用的参数 JSON

charDelta 则是这一片贡献了多少字符(思考文本也算进去),驱动方拿它累加成"本轮流了多少字"的进度指示。

加上流式生命周期里另外两个 mode,一共五种,并有优先级(MODE_PRIORITY,src/agent/agent.ts:661):一片 chunk 里若同时有 text 和 tool_use,取"更靠后的阶段"作为该片的 mode。完整生命周期:

requesting ──▶ thinking / responding / tool-input ──▶ tool-use
(发请求了) (流式内容按形状分类) (流完且有工具要执行)
优先级0 2 1 3 4
  • requesting:第一片到来之前先 yield 一次(src/agent/agent.ts:320),让 UI 立刻显示"请求已发出"。
  • tool-use:流结束、确认有 tool_calls 时补发(src/agent/agent.ts:348)。

这些 mode 定义在 StreamMode 类型(src/agent/types.ts:219)。

4.4 阻塞兜底

兜底路径极简单——一次性 invoke 拿完整结果,不流式:

// src/agent/agent.ts:366-375
private async callModelWithMessages(messages) {
const result = await callLlmWithMessages(messages, { model, tools, signal });
return { response: result.response as AIMessage, usage: result.usage };
}

代价是没有逐字进度(UI 停在 requesting),但保证能拿到结果。

拿到 response 后,run 把 usage 累加进 tokenCounter、记下 lastApiInputTokens(src/agent/agent.ts:200-203),然后进入本章最核心的分叉。


5. 核心分叉:有工具调用 vs 直接终答

这是整个循环的"命门"——一次模型响应回来后,只有两种走向:

response 回来了


hasToolCalls(response) ?

┌───┴────────────────────────┐
否 是
│ │
▼ ▼
直接终答 要调工具
handleDirectResponse ① push(response) 进便签
yield done ② 执行工具 → 收 ToolMessage → push 进便签
【return,循环结束】 ③ 落盘/预算/压缩/drain 队列
【回到 while 顶,进下一圈】

5.1 判定:hasToolCalls

判定函数极朴素——看这条 AIMessage 有没有非空的 tool_calls 数组(src/utils/ai-message.ts:24):

export function hasToolCalls(message: AIMessage): boolean {
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
}

「模型想不想继续动手」这个大决策,就压缩成这一行布尔判断。

5.2 一个细节:有工具调用时,顺便把思考播出去

如果模型这轮既写了文字、又要调工具(边说"我先去查一下财报"边发起工具调用),那段文字会作为 thinking 事件先播报出来、也记进 scratchpad(src/agent/agent.ts:208-212):

if (responseText?.trim() && hasToolCalls(response)) {
ctx.scratchpad.addThinking(trimmedText);
yield { type: 'thinking', message: trimmedText };
}

反之,若没有工具调用,那段文字就是最终答案,走终答路径,不当思考。

5.3 终答路径:handleDirectResponse

没工具调用,直接收尾。handleDirectResponse(src/agent/agent.ts:454)就是打包一个 done 事件并 yield,然后 run 里紧跟着 return 结束整个 generator:

// src/agent/agent.ts:215-218
if (!hasToolCalls(response)) {
yield* this.handleDirectResponse(responseText ?? '', ctx);
return; // ← generator 到此终止,循环彻底结束
}

done 里带齐了收尾信息:answer(答案文本)、toolCalls(scratchpad 里的全部工具调用记录)、iterationstotalTimetokenUsagetokensPerSecond

5.4 有工具调用路径:贴便签 → 执行 → 收结果 → 再贴

有工具调用时,先把这条 AIMessage(含 tool_calls)推进便签,再执行工具:

// src/agent/agent.ts:221-243(结构节选)
messages.push(response); // AIMessage 进便签
let { toolMessages, denied } = yield* this.executeToolsAndCollectMessages(response, ctx);
// …大结果落盘 persistLargeResult / 预算裁剪 enforceResultBudget(细节→03)…
messages.push(...toolMessages); // 工具结果进便签

executeToolsAndCollectMessages(src/agent/agent.ts:385)是本章与第 02 章的接缝:它把执行委托给 AgentToolExecutor(可并发跑只读工具),一路把 tool_start/tool_end/tool_error/tool_denied 等事件透传出去,并把每个结果按 tool_call_id 收成 ToolMessage

有两个设计值得记住:

  1. 结果按原始 tool_calls 顺序重排(src/agent/agent.ts:419)——并发执行下事件是乱序到达的,但塞回便签时必须和工具调用一一对应、保持顺序,否则模型对不上号。缺结果的位置补一条占位 ToolMessage
  2. 被拒即终止(src/agent/agent.ts:245):任一工具被用户拒绝(denied),本轮直接 yield done 收尾、return——不再继续循环。工具的权限/审批闸门属第 02 章。

执行完后还有落盘、预算、超阈值压缩、drain 队列几步(§6、§8),然后回到 while 顶进下一圈。


6. 事件模型:AgentEvent 是 run 与外界的唯一契约

6.1 为什么用事件流,而不是返回值

run 是 generator,唯一对外接口就是它 yield 的事件序列。好处是:一次可能几十秒、来回调好几个工具的长任务,能实时把每一小步播报出去,UI 边收边渲染,而不是黑箱等到最后。

所有事件形状收在 AgentEvent 联合类型里(src/agent/types.ts:294):

事件何时 yield携带什么
stream_progress每个流式 chunkcharDeltamode(§4.3)
thinking模型边思考边要调工具时message
tool_start/tool_end/tool_error工具生命周期toolargsresult/errortoolCallId
tool_progress子 agent 类工具的中途进度messagetoolCallId
tool_approval/tool_denied/tool_limit审批与用量护栏见 02 章
microcompact/compaction/context_cleared上下文管理各阶段见 03 章
memory_recalled/memory_flush记忆载入/落盘见 04 章
queue_drain中途新消息被并入messageCountmergedText
done唯一的收尾事件见下

6.2 DoneEvent:唯一的终点

无论怎么结束——正常终答、被拒、报错、跑满迭代——最后一定 yield 一个 done(DoneEvent,src/agent/types.ts:281),它是循环的唯一出口标志:

interface DoneEvent {
type: 'done';
answer: string; // 最终答案(报错时是错误文案,跑满时是提示语)
toolCalls: Array<{ tool; args; result }>;
iterations: number;
totalTime: number;
tokenUsage?: TokenUsage;
tokensPerSecond?: number;
}

代码里 yield ... type: 'done' 出现在多处:终答(§5.3)、被拒(:245)、LLM 报错兜底(:187)、跑满迭代(:280)。驱动方只要收到 done 就知道这一轮彻底结束了。


7. Agent.create:开跑之前,工具和系统提示怎么装配

run 能开跑,前提是 Agent.create(src/agent/agent.ts:77)已经把两样东西准备好:这次能用哪些工具系统提示长什么样

7.1 工具的三层过滤

getTools(model) 全量工具(按模型能力挑)

├─ toolAllowlist 过滤(可选) 委派 worker 只给一小撮工具

└─ CLI_ONLY_TOOLS 频道过滤 非 CLI 频道丢掉需要真人交互的工具

第三层是本章要点。有些工具必须有个真人坐在键盘前才有意义——ask_user_question(向用户提问)和 bash(要审批的命令)。它们收在 CLI_ONLY_TOOLS 里(src/agent/agent.ts:29),在非 CLI 频道(WhatsApp、gateway 等无人值守场景,见 06 章)一律剔除:

// src/agent/agent.ts:85-88
const isCli = !config.channel || config.channel === 'cli';
if (!isCli) {
tools = tools.filter(t => !CLI_ONLY_TOOLS.has(t.name));
}

配套还取一张 getToolConcurrencyMap(model)(名字→能否并发的查表),交给 AgentToolExecutor 决定哪些工具能并行跑(细节属 02 章)。

7.2 系统提示:两条装配路径

config.systemPromptOverride 有值?
├─ 是 ▶ 直接用它(委派 worker 的自包含提示,不带 soul/rules/memory)
└─ 否 ▶ buildSystemPrompt(model, soul, channel, group, memoryFiles, memoryContext, rules)
拼入:人格文档 + 规则文档 + 记忆文件清单 + 会话记忆上下文 + 频道口吻
  • override 路径(src/agent/agent.ts:94):委派出去的子 worker 用一份自包含提示,跳过 soul、rules、记忆,轻装上阵(见 05 章)。
  • 正常路径(src/agent/agent.ts:98-120):载入人格(loadSoulDocument)、规则(loadRulesDocument),开了记忆就从 MemoryManager 拉记忆文件清单和会话上下文(见 04 章),最后交给 buildSystemPrompt(src/agent/prompts.ts:215)按频道(CLI/WhatsApp…)拼出不同口吻与格式规则的完整系统提示。

装配好后 new Agent(...) 把工具、提示、并发表存进实例,run 就只管用了。


8. 驱动方:AgentRunnerController 怎么消费这条事件流

run 只负责产事件;把事件变成屏幕上的东西、并回应 agent 的"我要审批/我要问你",是 AgentRunnerController(src/controllers/agent-runner.ts)的活。CLI 层(src/cli.ts:468)调 runQuery(query) 起步。

8.1 runQuery:起一轮、收事件流

// src/controllers/agent-runner.ts:189-203(结构节选)
const agent = await Agent.create({
...this.agentConfig,
signal: this.abortController.signal, // 取消用
requestToolApproval: this.requestToolApproval, // 审批回调
requestUserInput: this.requestUserInput, // 提问回调
sessionApprovedTools: this.sessionApprovedTools, // 会话级已批工具
messageQueue: defaultQueue, // 中途插话队列
});
const stream = agent.run(query, this.inMemoryChatHistory);
for await (const event of stream) {
if (event.type === 'done') finalAnswer = event.answer;
await this.handleEvent(event); // 每个事件更新 UI 状态
}

handleEvent(src/controllers/agent-runner.ts:260)是个大 switch,把每类事件映射成对内部 history/workingState 的更新,再 emitChange() 触发重渲染。

8.2 审批与提问:用 Promise 把异步交互"缝"进 generator

工具需要审批、或 agent 要向用户提问时,run 内部会 await 一个由驱动方提供的回调返回的 Promise。驱动方的做法很巧:把这个 Promise 的 resolve 函数存起来,先把 UI 切到"等待审批/等待回答"态;等用户在界面点了按钮,再调对应方法 resolve,agent 那边的 await 才继续:

// src/controllers/agent-runner.ts:242-249
private requestToolApproval = (request) => {
return new Promise<ApprovalDecision>((resolve) => {
this.approvalResolve = resolve; // 把 resolve 存住
this.pendingApprovalValue = request; // 弹出审批 UI
this.workingStateValue = { status: 'approval', toolName: request.tool };
this.emitChange();
});
};
// 用户点了按钮 → respondToApproval(decision) → this.approvalResolve(decision)(:115)

requestUserInput(:251)同理。cancelExecution(:139)则在取消时 abort() 信号、并把悬着的审批/提问 Promise 以 deny/declined 兜底 resolve,不让 agent 卡死。

8.3 一个性能细节:per-chunk 不触发重渲染

流式每片 chunk 都会 yield 一个 stream_progress,一秒可能几十上百个。如果每个都 emitChange() 重渲染,UI 会被刷爆、连输入都卡。驱动方的处理是:stream_progress 只累加计数、return 掉、不 emitChange;由"工作指示器"自己的定时 tick 去拉当前 turnStats 显示:

// src/controllers/agent-runner.ts:344-350
case 'stream_progress':
// 只更新累加器,不触发 onChange —— 避免 per-chunk 重渲染风暴
this.streamedCharsValue += event.charDelta;
this.streamModeValue = event.mode;
return;

8.4 消息队列 drain:你可以中途插话

agent 埋头干活时,你又敲了一句新消息——不会打断当前轮,而是进 defaultQueue。主循环每圈末尾 drainQueue()(src/agent/agent.ts:271)把队列里攒的消息合并成一条 HumanMessage 追加进便签,并 yield queue_drain;下一圈模型就看到你的补充了。若某轮结束后队列还有残留,runQuery递归再起一轮把它们处理掉(src/controllers/agent-runner.ts:206-210)。


9. 模型抽象:一套代码打十几家 provider

主循环调模型时只说 streamLlmWithMessages(messages, { model, tools, signal }),完全不关心背后是 OpenAI 还是 Anthropic。这层解耦在 src/model/llm.tssrc/providers.ts

9.1 provider 路由:靠模型名前缀

PROVIDERS唯一事实源——一张 provider 元数据表(src/providers.ts:21),每行含 id、显示名、模型名前缀、API key 环境变量、fastModel、contextWindow:

providermodelPrefixfastModelcontextWindow
openai''(默认)gpt-5.6-luna1,047,576
anthropicclaude-claude-haiku-4-5200,000
googlegemini-gemini-3-flash-preview1,000,000
xaigrok-grok-4-1-fast-reasoning131,072
deepseekdeepseek-deepseek-v4-flash1,000,000
…(moonshot/openrouter/ollama/…)各自前缀

resolveProvider(modelName)(src/providers.ts:99)就是按前缀匹配:模型名以 claude- 开头就是 Anthropic,gemini- 就是 Google,都不匹配则兜底 OpenAI(前缀为空串)。

export function resolveProvider(modelName: string): ProviderDef {
return PROVIDERS.find((p) => p.modelPrefix && modelName.startsWith(p.modelPrefix))
?? defaultProvider; // 兜底 OpenAI
}

两个字段直接喂给主循环用途:fastModel——压缩/摘要用的便宜快模型(见 03 章);contextWindow——推导自动压缩阈值(getAutoCompactThreshold,src/utils/tokens.ts:50,= 有效窗口 − 13K 缓冲)。

9.2 getChatModel:工厂按 provider 造 client

getChatModel(modelName, streaming)(src/model/llm.ts:155)先 resolveProvider 定 provider,再从 MODEL_FACTORIES 表(:68)取对应工厂造出 LangChain chat model;表里没有的落到 DEFAULT_FACTORY(OpenAI,:146)。各工厂差异都藏在这:xAI/Moonshot/DeepSeek 复用 ChatOpenAI 但换 baseURL;GPT-5.6 系列在推理+函数工具并用时要走 Responses API(useResponsesApi: name.startsWith('gpt-5.6-'),:152)。

9.3 两个调用出口 + 重试

主循环用两个函数:

  • streamLlmWithMessages(src/model/llm.ts:359):流式,getChatModel(model, true) + bindTools,for awaitAIMessageChunk。§4 的首选路径。
  • callLlmWithMessages(src/model/llm.ts:316):阻塞,一次 invoke。§4.4 的兜底路径。

两者都经 withRetry(src/model/llm.ts:30)包一层——指数退避重试:最多 3 次,退避 500 * 2^attempt 毫秒(0.5s、1s、2s);但先用 isNonRetryableError 判是否该重试(如 4xx 鉴权/参数错就直接抛,不浪费重试):

// src/model/llm.ts:39-47(节选)
if (isNonRetryableError(message)) throw new Error(`[${provider} API] ${message}`);
if (attempt === maxAttempts - 1) throw new Error(`[${provider} API] ${message}`);
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); // 指数退避

9.4 Anthropic 的 prompt caching

对 Anthropic,两个出口都会先给 SystemMessage 打上 cache_control: { type: 'ephemeral' } 标记(annotateSystemMessageForCaching,src/model/llm.ts:276;单 prompt 版是 buildAnthropicMessages,:211)。系统提示是每轮都重复发的长前缀,缓存它能省约 90% 的输入 token 成本。OpenAI/Gemini 有自动缓存,不需这步。


10. 边界与局限(诚实)

  • 硬迭代上限 10。 复杂研究若十圈内没收尾,直接给"达到最大迭代数"的兜底答复(src/agent/agent.ts:280),不是真正的完成。
  • 上下文溢出只重试 2 次。 报"上下文超限"时,循环里靠 truncateMessages 砍旧轮再试,最多 MAX_OVERFLOW_RETRIES = 2 次(src/agent/agent.ts:25:176);再不行就以错误 done 收场。
  • 被拒即终止整轮。 任一工具审批被拒,当前 query 立刻结束(:245),不会跳过该工具继续——粒度较粗。
  • 流式模式是"推断"的。 inspectChunkContent 靠 chunk 内容形状猜 thinking/responding/tool-input,provider 若换了 content 结构,推断可能失准(纯字符串一律记为 responding)。
  • 压缩/记忆/工具细节不在本章。 便签超阈值后的 memory flush → compaction → truncate 三级处理(manageContextThreshold,src/agent/agent.ts:556)见 03;工具并发与权限见 02

11. 巧妙之处(可借鉴)

  • 用 async generator 当 agent 的对外接口。 长任务的每一小步都能实时播报,UI 边收边渲染,而不是黑箱等最终结果。yield* 让多层 generator 的事件一竿子透传到底(src/agent/agent.ts:168)。
  • "有没有 tool_calls" 就是整个循环的开关。 继续动手还是收尾终答,压缩成一行 hasToolCalls 布尔判断(src/utils/ai-message.ts:24),循环结构因此极干净。
  • 流式 chunk 用 .concat() 累积,把分片流过来的工具参数 JSON 拼完整,再造正式 AIMessage(src/agent/agent.ts:329)。
  • 异步用户交互用"存 resolve、事件驱动 resolve"缝进 generator(src/controllers/agent-runner.ts:242)——审批/提问天然是异步 UI,却能被 run 里一个 await 自然等待。
  • per-chunk 不重渲染,只累加、靠定时器拉状态,避免流式刷爆 UI(src/controllers/agent-runner.ts:344)。
  • provider 靠模型名前缀路由 + 单一 PROVIDERS(src/providers.ts:99),加一家新 provider 只改一处;Anthropic 系统提示缓存省约 90% 输入成本(src/model/llm.ts:276)。

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

主题文件路径符号名
主循环本体(生成器)src/agent/agent.tsAgent.run
初始消息组装 / while 条件src/agent/agent.tsrun(messages 初始化、while (ctx.iteration < this.maxIterations))
装配工具与系统提示src/agent/agent.tsAgent.create
CLI-only 工具频道过滤src/agent/agent.tsCLI_ONLY_TOOLS
流式调模型 + 累积src/agent/agent.tscallModelWithStreaming / streamAndAccumulate
阻塞兜底调用src/agent/agent.tscallModelWithMessages
流式 chunk 模式推断src/agent/agent.tsinspectChunkContent / MODE_PRIORITY
有工具调用 vs 终答判定src/utils/ai-message.tshasToolCalls / extractTextContent
终答收尾src/agent/agent.tshandleDirectResponse
执行工具收结果src/agent/agent.tsexecuteToolsAndCollectMessages
队列 drainsrc/agent/agent.tsdrainQueue
一次 run 的状态src/agent/run-context.tsRunContext / createRunContext
token 累计src/agent/token-counter.tsTokenCounter
事件联合类型 / 终点src/agent/types.tsAgentEvent / DoneEvent / StreamMode
驱动方:起轮 + 消费事件src/controllers/agent-runner.tsAgentRunnerController.runQuery / handleEvent
审批/提问 Promise 回调src/controllers/agent-runner.tsrequestToolApproval / requestUserInput / respondToApproval
模型工厂 / 调用 / 重试src/model/llm.tsgetChatModel / callLlmWithMessages / streamLlmWithMessages / withRetry
Anthropic 提示缓存src/model/llm.tsannotateSystemMessageForCaching / buildAnthropicMessages
provider 表 / 前缀路由src/providers.tsPROVIDERS / resolveProvider / getProviderById
自动压缩阈值src/utils/tokens.tsgetAutoCompactThreshold / KEEP_TOOL_USES

相邻章节:Dexter 全景 · 工具层与权限 · 上下文工程 · 记忆系统 · 子 agent 与 Skill · 无人值守与多渠道交付