跳到主要内容

Agent 主循环:流式工具调用

30 秒导读: 用户在 nao 聊天框里问一句"上个月哪个渠道转化最高?",后端并不是"问一次答一次"。 它把这次提问装配成一个会自己调工具的 agent:模型先想,调 execute_sql 查库,拿到结果再想, 也许再画个图,最后总结——这一整串"生成→调工具→再生成"的循环,就是本章的主角。我们只讲 这个循环怎么被驱动起来、怎么边跑边流式吐字、怎么在结束时落库;不讲每个工具内部怎么实现(见 03-tools.md),也不讲系统提示的文本怎么拼(见 04-system-prompt.md)。

本章的全部戏都发生在一个文件里:apps/backend/src/services/agent.ts。读完你能讲清楚:一次 HTTP 请求进来后,是谁解析了模型、谁决定了这个循环"什么时候停"、模型说的话是怎么变成前端能渲染的流、 以及为什么每一步之前都要"剪枝 + 打缓存标记"。


1. 先建立直觉:什么是"Agent 主循环"

1.1 一次问答不是一次调用

朴素的 LLM 调用是"一发一收":给提示,收一段文字。但数据分析要动手——查数据库、跑 Python、 画图表。模型不能自己查库,它只能"说":我想调 execute_sql,参数是这段 SQL。于是后端要:

  1. 把模型这句"我想调工具"接住;
  2. 真的去执行那个工具(连库、跑查询);
  3. 把结果塞回对话,再让模型继续;
  4. 重复,直到模型说"我讲完了"。

这个"生成一步 → 若要调工具就执行 → 把结果喂回去 → 再生成"的往复,就是 agent loop(智能体主循环)。 一次用户提问,可能在后端滚动好几步(step),才有最终答案。

1.2 nao 不自己写循环,它"外包"给 SDK

关键设计:nao 不手写这个 while 循环。它用 Vercel AI SDK(ai 包,v6)提供的 ToolLoopAgent——一个已经把"多步工具循环"封装好的类。nao 的活是装配:选好模型、备好工具、 定好"何时停",把这些交给 ToolLoopAgent,然后调它的 stream() 让循环跑起来。

一句话类比: ToolLoopAgent 是一台"自动驾驶的对话引擎",nao 是"上车前的配置员"—— 加什么油(模型)、带什么工具、走到哪儿算到站(停止条件),都由 nao 在发车前设好;开起来之后的 每一步换挡由引擎自己完成。

1.3 两个人格:AgentService 和 AgentManager

agent.ts 里有两个类,别名一下就不会混:

角色一句话职责位置
AgentService工厂 + 登记处解析模型/工具/上下文/停止条件,造出一个 agent,并按 chatId 登记services/agent.ts:159
AgentManager一次会话的引擎壳持有 ToolLoopAgent,提供 stream() / generate(),负责装配消息、流式、落库services/agent.ts:308

AgentService 是单例(文件末尾 export const agentService = new AgentService(),services/agent.ts:909); 每个活跃 chat 对应一个 AgentManager,存在 AgentService._agents 这张 Map<chatId, AgentManager> 里。


2. 顶层全景:一次提问怎么被驱动成循环

先看"大盘"。下面这张图从左到右是一次提问的生命周期,先走一遍,后面各节再逐个放大。

HTTP POST /agent
│ (Fastify 路由, routes/agent.ts)

┌──────────────────────┐
│ handleAgentRoute │ 校验预算 → 落库用户消息 → 初始化 MCP/skills
│ (handlers/agent.ts) │
└──────────┬───────────┘
│ agentService.create(chat, model)

┌───────────────────────────────────────────────┐
│ AgentService.create │ ← "发车前配置"
│ • 解析模型 _getResolvedLlmSelectedModel │
│ • 预算闸门 assertBudgetNotExceeded │
│ • 造模型 _getModelConfig → providers.ts │
│ • 备工具 getTools(...) + web 工具 │
│ • 建上下文 ToolContext(projectFolder, ...) │
│ • 定停条件 stopWhen[] │
│ └────────────► new AgentManager(...) │
└───────────────────────┬────────────────────────┘
│ agent.stream(messages)

┌───────────────────────────────────────────────┐
│ AgentManager.stream (createUIMessageStream) │
│ 1. _buildModelMessages 装配管线(见 §7) │
│ 2. ToolLoopAgent.stream ── 多步工具循环 ──┐ │
│ ┌─────────── loop ───────────┐ │ │
│ │ 生成一步(可含工具调用) │ │ │
│ │ prepareStep: 剪枝+缓存标记 │◄────┘ │
│ │ 若调工具 → 执行 → 结果回喂 │ │
│ │ 检查 stopWhen,未命中则继续 │ │
│ └────────────────────────────┘ │
│ 3. writer.merge(UI 流) ── 边跑边吐 chunk ──► │──► 前端
│ 4. 异步:抽记忆 / 生成标题 │
│ 5. onFinish → upsertMessage 落库 │
└───────────────────────────────────────────────┘

部件一句话职责:

部件干什么在哪
agentRoutesFastify POST /,把请求转给 handler,返回流式响应routes/agent.ts:13
handleAgentRoute预算校验、落库用户消息、初始化 MCP/skills,再造 agenthandlers/agent.ts:26
AgentService.create一次运行的"装配总装线"services/agent.ts:172
providers.ts把 provider+modelId 变成一个 AI SDK 的 LanguageModel 实例agents/providers.ts:162
getTools按开关/allowlist 组装这次要暴露给模型的工具带agents/tools/index.ts:34
AgentManager.stream装配消息 → 跑循环 → 流式 → 落库services/agent.ts:377
ToolLoopAgentAI SDK 提供的多步工具循环引擎(nao 不实现它)ai 包(v6)

3. 装配总装线:AgentService.create

这节讲:发车前那一串"配置动作",全在 create 里,services/agent.ts:172-231

create 是同步"造 agent"的唯一入口。它按顺序做完六件事,产出一个 AgentManager。逐个看:

3.1 先清场:一个 chat 只留一个活跃 agent

// services/agent.ts:204 — 同一 chat 若已有 agent,先 dispose(stop + 从 Map 删除)
this._disposeAgent(chat.id);

这保证"同一个对话不会有两个循环同时在跑"——旧的先被 abort()(_disposeAgent → agent.stop(), services/agent.ts:269-276)。

3.2 解析模型:调用方没指定就用项目默认

_getResolvedLlmSelectedModel(services/agent.ts:233-258)决定这次用哪个 {provider, modelId}:

传入了 modelSelection? ── 是 ──► 直接用
│否

项目有 LLM 配置? ── 有 ──► 用第一个配置的 provider + 该 provider 的默认 modelId
│无

环境变量里有 provider? ── 有 ──► 用 env 里第一个
│无

抛 BAD_REQUEST 'No model config found'

3.3 预算闸门:超了直接不发车

// services/agent.ts:206
await assertBudgetNotExceeded(chat.projectId, resolvedLlmSelectedModel.provider);

assertBudgetNotExceeded(utils/budget.ts:26)按 provider 算"本期已花 / 预算上限"的比值, ratio >= 1 就抛 BudgetExceededError,并顺手给管理员发"预算用尽"邮件。注意时序:路由层的 handleAgentRoute 在最前面也调了一次 agentService.assertBudget(handlers/agent.ts:33), create 里这次是第二道闸——落库用户消息之后、真正发车之前再确认一遍。

3.4 造模型:交给 providers.ts

// services/agent.ts:207
const modelConfig = await this._getModelConfig(chat.projectId, resolvedLlmSelectedModel);

_getModelConfig → resolveProviderModel → createProviderModel(agents/providers.ts:162)把抽象的 {provider, modelId} 变成一个真正能调用的 LanguageModelV3 实例,外加 providerOptions(provider 专属选项)和 contextWindowLLM_PROVIDERS(agents/providers.ts:30)是一张"provider → 怎么建模型" 的表,支持 Anthropic / OpenAI / Google / Mistral / OpenRouter / Ollama / Bedrock / Vertex / Azure 九家。

一个值得记住的细节:Anthropic 的 defaultOptions 里预置了服务端上下文管理 (clear_tool_uses_20250919,输入超 18 万 token 时自动清理旧工具调用,但豁免一批读类工具), 见 agents/providers.ts:36-58。这是"模型侧"的省 token 机制,和 nao 自己在 _pruneMessages 做的 "客户端剪枝"(见 §6.1)是两条独立的战线。

3.5 备工具:内置工具带 + web 搜索 + MCP

// services/agent.ts:210-212
const webTools = await this._resolveWebTools(chat.projectId, provider, agentSettings);
const resolveTools = options.tools ?? defaultAgentTools;
const agentTools = await resolveTools({ chat, agentSettings, toolContext, webTools });

工具集不是写死的,而是一个可注入的解析器 AgentToolsResolver(services/agent.ts:107)。默认用 defaultAgentTools(services/agent.ts:110),它转手调 getTools(...)(工具带怎么按开关拼出来,见 §8)。 自动化等非交互场景可以传自己的 resolver 来收窄工具。Web 搜索工具是按需解析的:只有 agentSettings.webSearch.enabled 且该 provider 支持时才创建(_resolveWebTools,services/agent.ts:282-295)。

3.6 建 ToolContext:工具运行时能摸到的一切

_getToolContext → buildToolContext → _buildContextBase(services/agent.ts:132-157)攒出一个 ToolContext——每个工具执行时都能拿到它(项目根路径、userId、环境变量、Azure token,以及两个 可变累加器:queryResults 存 SQL 查询结果、generatedArtifacts 存这次生成的图表/故事)。 这个对象最终被塞进 ToolLoopAgentexperimental_context(见 §4),SDK 会把它透传给每个工具。

ToolContext 是循环内的"共享内存": 第一步 execute_sql 把结果写进 queryResults, 后面 display_chart 就能从同一个 Map 里取出来引用,而不必把整张表再喂回模型。上下文即文件系统的 思路,详见 01-context-filesystem.md

3.7 定停止条件:循环"走到哪算到站"

这是整个循环的"刹车",也是最容易被忽略的设计:

// services/agent.ts:213-217
const stopWhen: StopCondition<AgentTools>[] = options.excludeFollowUps
? [stepCountIs(options.maxSteps ?? 20)]
: chat.testMode
? [hasToolCall('suggest_follow_ups')]
: [hasToolCall('suggest_follow_ups'), hasToolCall('clarification')];

停止条件用工具调用来表达,分三种情况:

场景停止条件含义
交互式(默认)hasToolCall('suggest_follow_ups') hasToolCall('clarification')模型调"建议追问"或"要求澄清"任一工具 → 收工
测试模式 testModehasToolCall('suggest_follow_ups')测试环境不注入 clarification 工具,所以只认前者
非交互 excludeFollowUpsstepCountIs(maxSteps ?? 20)自动化/后台分析没有"追问"概念,改用步数上限兜底

为什么用"调某个工具"当终点线,而不是"模型不再说话"? 因为 nao 想让循环必然以一个结构化动作收尾: 交互式会话必须以 suggest_follow_ups(给用户几个后续问题)结束,产品体验统一;而自动化流程如果也用 "建议追问"当终点,循环会过早停在发出外部集成工具之前——所以它换成纯步数上限,让后续动作有机会跑完 (这正是 excludeFollowUps 注释里说明的动机,services/agent.ts:183-194)。

配好这七样,create 把它们全部塞进 new AgentManager(...)(services/agent.ts:218-228),登记进 _agents Map,返回。装配到此结束。


4. 引擎壳:AgentManager 怎么封 ToolLoopAgent

这节讲:AgentManager 构造时干的唯一一件事——new ToolLoopAgent(...),services/agent.ts:323-331

// services/agent.ts:323 — 把装配好的一切交给 SDK 的循环引擎
this._agent = new ToolLoopAgent({
model: this._modelConfig.model,
providerOptions: this._modelConfig.providerOptions,
tools: this._agentTools,
maxOutputTokens: MAX_OUTPUT_TOKENS, // 16_000, services/agent.ts:306
prepareStep: async ({ messages }) => this._prepareStep(messages),
stopWhen,
experimental_context: this._toolContext,
});

ToolLoopAgent 是 AI SDK v6 里"会调工具的 agent"抽象。nao 只填七个字段,循环本身由 SDK 实现:

字段nao 填什么作用
modelprovider 造出的语言模型实例每一步向哪个模型请求
providerOptionsprovider 专属选项如 Anthropic 的上下文管理、OpenAI 的 store:false
tools装配好的工具带模型能调哪些工具
maxOutputTokens16000单步生成上限
prepareStep_prepareStep 回调每一步开始前改写消息(剪枝 + 缓存标记,见 §6)
stopWhen§3.7 的停止条件何时结束循环
experimental_contextToolContext透传给每个工具执行

AgentManager 对外只暴露两个"发车键":stream()(交互式,流式)和 generate()(非交互,一把梭)。 两者共用同一个 _agent,区别只在调 SDK 的 .stream() 还是 .generate()

注意: 构造里有一段被注释掉的 compactionService.compactConversationIfNeeded (services/agent.ts:335-354)——"对话压缩"目前在主循环里是关闭的,_prepareStep 实际只做剪枝 和缓存标记。这是诚实要点:代码里能看到压缩的接线,但当前 commit 未启用。


5. 交互路径:stream() 的流式与落库

这节讲:stream(),services/agent.ts:377-464。它是聊天 UI 走的路。

stream() 返回一个 ReadableStream,内容用 AI SDK 的 createUIMessageStream 包装—— UIMessage 流是一种前端能直接增量渲染的结构化 chunk 流(文字片段、工具调用状态、自定义数据事件)。 它的 execute 回调按顺序做这几件事:

execute({ writer }):
① writer.write({type:'start'}) 开流
② 若是新会话 → 推 data-newChat / data-newUserMessage 事件
③ messages = await _buildModelMessages(...) 装配模型消息(见 §7)
④ result = await _agent.stream({ messages, abortSignal }) ← 循环发车
⑤ _scheduleMemoryExtraction(uiMessages) 异步抽记忆(不 await)
⑥ 若新会话 → _scheduleTitleGeneration(...) 异步起标题(不 await)
⑦ writer.merge(result.toUIMessageStream()) 把循环产出的流并进来 ── 边跑边吐

关键点拆开说:

  • 发车即返回流。 第 ④ 步 await _agent.stream(...) 拿到的是一个惰性的结果句柄,循环还没跑完; 第 ⑦ 步 writer.merge 把 SDK 的 UIMessage 流并入外层 writer,于是模型每吐一个 token、每发一次工具 调用,都会实时流到前端。sendStart:false 是因为外层 ② 已经先写过 start 了(services/agent.ts:431-433)。

  • 记忆抽取和标题生成是"发出即忘"。 第 ⑤⑥ 步在请求刚发出后就调度,不 await,不阻塞流。 记忆抽取(_scheduleMemoryExtraction → memoryService.safeScheduleMemoryExtraction,services/agent.ts:602-610) 在后台从本轮对话里抽取用户长期记忆;标题生成只在新会话时触发(见 §5.1)。

5.1 标题生成:一次独立的小 LLM 调用

_generateTitle(services/agent.ts:622-663)是主循环之外的旁路:用一个便宜的"摘要模型" (LLM_PROVIDERS[provider].summaryModelId)对用户第一句话做一次 generateText,配 Output.object 强约束出 {title}(3–8 词),写库 renameChat,再尝试往流里推一个 data-chatTitleUpdate 事件让前端即时更新标题。 流可能已关,所以那次推送包在 try/catch 里静默失败(services/agent.ts:658-662)。

5.2 收尾即落库:onFinish

循环真正跑完(或被中断)后,createUIMessageStreamonFinish 触发(services/agent.ts:445-462):

// services/agent.ts:447-458 — 把这一轮 assistant 消息落库
const stopReason = e.isAborted ? 'interrupted' : e.finishReason;
const tokenUsage = await this._getTotalUsage(result);
const [settledMessage] = settleInterruptedToolParts([e.responseMessage]);
await chatQueries.upsertMessage({
...settledMessage,
chatId: this.chat.id,
stopReason, error, tokenUsage,
llmProvider: this._modelSelection.provider,
llmModelId: this._modelSelection.modelId,
});

三个要点:

  1. 中断也算数。 用户中途点停 → abort()e.isAborted 为真 → stopReason='interrupted', 消息照样落库。这样刷新页面能看到"半截答案"。
  2. 未闭合的工具调用要"结算"。 settleInterruptedToolParts(utils/ai.ts:108)把被打断、 状态卡在半空的工具 part 补成一个终态,避免存进库的消息结构不合法。
  3. 无论成败都释放。 finally { this._onDispose() } 把这个 AgentManagerAgentService._agents Map 里删掉——引擎壳用完即弃。

6. 每一步的预处理:prepareStep

这节讲:_prepareStep,services/agent.ts:334-357。它在循环的每一步开始前被 SDK 回调。

当前它做两件事,顺序是先剪枝、再打缓存标记:this._addCache(this._pruneMessages(messages))

6.1 _pruneMessages:把历史里的噪音剪掉

多步循环里,历史会越滚越长。_pruneMessages(services/agent.ts:831-838)用 AI SDK 的 pruneMessages 做三件裁剪:

剪什么规则为什么
推理内容 reasoningbefore-last-message(只留最后一条的推理)旧的思维链对后续步骤没用,纯占 token
suggest_follow_ups 的工具调用type:'all'(整个删掉)那是"给用户看的追问",不该污染模型的后续上下文
空消息 emptyMessagesremove剪完可能留下空壳,清掉

6.2 _addCache:给 Anthropic 系模型打提示缓存断点

_addCache → addPromptCache(utils/prompt-cache.ts:13)只对 Anthropic 家族(直连 Anthropic、 Vertex 上的 Claude、Bedrock 上的 Anthropic 模型)生效,别的 provider 原样返回。它在消息数组上放两个 缓存断点:

[ system 消息 ] ← 打 1h 缓存(指令很少变,长 TTL)
[ ...历史... ]
[ 最后一条消息 ] ← 打 5m 缓存(当前步的"叶子",供 agentic 缓存复用)

对应 addPromptCache 里的两处:系统消息用 CACHE_1H,末条用 CACHE_5M(utils/prompt-cache.ts:21-26)。 含义:系统提示这段前缀在多轮里几乎不变,缓存一小时;每一步新增的尾巴短命,缓存五分钟——这样后续 步骤命中缓存,省下重复处理长前缀的钱和时延。

为什么放在 prepareStep 里而不是一次性? 因为断点位置依赖"当前这一步的消息长什么样"。每步 消息都在变(工具结果不断追加),末条缓存断点必须每步重新贴到新的末尾,所以它天然属于每步预处理。


7. 装配管线:_buildModelMessages

这节讲:发车前把"前端来的 UIMessage"翻译成"模型吃的 ModelMessage"的一条流水线, services/agent.ts:469-500

前端传来的是富结构的 UIMessage(带工具 part、文件 part、故事 part……),模型要的是扁平的 ModelMessage。中间这条管线逐层加工,每一步都是"输入一批消息、输出一批消息"的纯函数式变换:

settleInterruptedToolParts 结算上一轮被中断的工具 part

_syncStoryToolOutputs 故事输出与 DB 同步 + 去重(见 §7.1)

_addStoryMode 若 @story mention → 注入"故事模式"指令

_addSkills 若 /skill mention → 用技能正文替换用户文本

_addCitationContext 若带引用选区 → 前置"用户在指某段文字"

_addDatabaseContext 若 @table mention → 附上被引用表的列信息

compactionService.useLastCompaction 套用最近一次压缩摘要(若有)

resolveImageUrls 把 /i/{id} 图片 URL 换成真实 base64(见 §7.2)

[system 消息] + 以上 systemPrompt 交给 _buildSystemPrompt(见 04 章)

convertToModelMessages AI SDK:UIMessage[] → ModelMessage[]

几条**注入(mentions)**都收敛到同一个小工具 _transformLastUserMessageText (services/agent.ts:797-814)——它们本质都是"往最后一条用户消息的文本前/后拼一段上下文":

注入触发拼什么位置
故事模式mention __story__[Story mode: ...] 前缀services/agent.ts:753-761
技能mention trigger:'/'用技能正文替换用户文本(截断到 16k)services/agent.ts:763-770
引用选区末条用户消息带 citation[用户在指第 x–y 字: "..."]services/agent.ts:742-751
数据库上下文mention trigger:'@'Referenced tables: [列信息] 后缀services/agent.ts:772-795

系统提示怎么拼是另一章的事——这里只标出交接点 _buildSystemPrompt(services/agent.ts:502-510): 它先看项目里有没有覆盖提示,没有才回退到 _buildDefaultSystemPrompt 组装默认提示。文本细节见 04-system-prompt.md

7.1 故事输出的同步与去重

_syncStoryToolOutputs(services/agent.ts:535-600)解决一个具体痛点:一个"故事"(story,一种可交互 的图文报告)可能在对话里被多次编辑,历史里就会躺着同一个 story 的多份全量代码,白白吃 token。策略是:

  • 先扫一遍,记下每个 storyId 最后一次出现的 toolCallId;
  • 对每个 story part:不是最后一次的,标 _stale:true 且把 code 清空(模型只看到一个短占位符); 是最后一次的,从 DB 拉最新版本(版本号、代码、标题、是否用户改过)填进去。

这样模型对每个故事只看到"最新的那份全文 + 早期的空壳",既保真又省 token。整段包在 try/catch 里, DB 出错就原样返回,不阻断发车(services/agent.ts:597-599)。

7.2 图片:把服务端 URL 还原成 base64

前端的图片 part 存的是 /i/{id} 这种服务端相对 URL。resolveImageUrls(services/agent.ts:858-906) 把它们批量查库、换成真实 base64。原因写在函数注释里:AI SDK 的 convertToModelMessages 会把 FileUIPart.url 映射成 FilePart.data,若传 data: 开头的 data-URL 会被误当成"可下载链接",所以 只传纯 base64 字符串(mediaType 是 part 上另一个字段)。


8. 非交互路径:generate()

这节讲:generate(),services/agent.ts:681-732。它是自动化/后台任务走的路,不流式

generate()stream() 共用 _buildModelMessages(所以装配管线完全一致),区别在于:

维度stream()generate()
SDK 调用_agent.stream(...)_agent.generate(...)
产出增量 UIMessage 流一次性 AgentRunResult
落库onFinishupsertMessage不落库,直接把结果返回给调用方
记忆/标题会调度不调度
用途聊天 UI自动化、上下文推荐等后台流程

generate() 返回结构化的 AgentRunResult(services/agent.ts:74-90):文本、token 用量、成本 (convertToCost,按 provider/model/自定义定价换算)、耗时、以及可直接用于后续调用responseMessages 和原始 steps(自动化流程要从 steps 里翻出工具调用来判断"该不该继续"或"发了哪些外部动作")。 同样 finally { this._onDispose() } 用完即弃。


9. 入口:routes/agent.ts 与流式响应

这节讲:一切的最外层——Fastify 路由,routes/agent.ts:13

agentRoutes 注册 POST /,链路是:鉴权中间件 → 校验权限(viewer 角色 403,routes/agent.ts:20-25) → handleAgentRoute 拿到 { stream } → 埋点 → 用 createUIMessageStreamResponse 把流包成 HTTP 响应。

响应头有两个为反向代理准备的关键设置(routes/agent.ts:56-64):

headers: {
'X-Accel-Buffering': 'no', // 禁用 nginx 缓冲
'Cache-Control': 'no-cache, no-transform',
}

X-Accel-Buffering: no 让 nginx 不要把流攒起来再一次性吐——否则用户会"卡半天,然后一下子刷出全文", 流式体验全废。注释直说这对"反向代理后的正确流终止"是关键的(routes/agent.ts:59-61)。

handleAgentRoute(handlers/agent.ts:26)是路由和 service 之间的胶水,发车前的准备都在这: 预算校验、新建或续写会话、落库用户消息(新会话 createChat;续写或编辑走 insertOrSupersedeMessage,编辑时先 supersedeMessagesFrom 把旧版本作废)、初始化 MCP 与 skills 状态, 最后 agentService.create(...) 造 agent 并 agent.stream(chat.messages, ...) 返回流。


10. 工具带怎么拼出来:getTools

这节讲:getTools,agents/tools/index.ts:34。§3.5 说过工具是解析出来的,这里看它到底怎么拼。

getTools 从一个静态 tools 表(agents/tools/index.ts:19-32,含 story、execute_sql、grep、read、 display_chart、suggest_follow_ups 等)出发,按一串开关增删,拼出这次运行真正暴露给模型的集合:

开关效果位置
mcpEnabled === false丢掉全部 MCP 工具agents/tools/index.ts:51
excludeFollowUps剔除 suggest_follow_ups(非交互场景不需要)agents/tools/index.ts:60
testMode剔除 clarification(测试模式不给澄清工具)agents/tools/index.ts:64
agentSettings.experimental.pythonSandboxing加入 execute_python(且该工具本身可用)agents/tools/index.ts:66
agentSettings.experimental.sandboxes加入 execute_sandboxed_codeagents/tools/index.ts:67
extraTools(如 web 搜索工具)无条件合并进来agents/tools/index.ts:68
builtinToolAllowlist白名单:只留名单内的内置工具(MCP/python/沙箱/澄清全丢),extraTools 始终保留agents/tools/index.ts:71-74

execute_python / execute_sandboxed_code 在静态表里就用 ...(cond && {...}) 条件展开 (agents/tools/index.ts:23-24)——环境本身不具备 Python 或沙箱能力时,连引入都不引入。白名单 是给"聚焦运行"用的:比如"上下文推荐"只该发现上下文,不该查数仓、不该画图,于是只允许一小撮读类工具。

工具带里每个工具内部怎么实现(SQL 怎么连库跑查询、图表怎么生成、沙箱边界在哪),是下一章的题目, 见 03-tools.md


11. 边界与要点(诚实清单)

  • 循环本体不是 nao 写的。 "生成→调工具→再生成"的 while 是 AI SDK 的 ToolLoopAgent 实现的; nao 的价值在装配(模型/工具/上下文/停止条件)和两端加工(装配管线 + 落库)。
  • 对话压缩当前关闭。 _prepareStepcompactConversationIfNeeded 被整段注释掉 (services/agent.ts:335-354),现在每步只做剪枝 + 缓存标记。代码里能看到接线,但未启用。
  • 提示缓存只惠及 Anthropic 系。 addPromptCache 对非 Anthropic/Bedrock-Anthropic 的 provider 直接原样返回(utils/prompt-cache.ts:15,30-42)。
  • 一个 chat 单活跃 agent。 create 开头 _disposeAgent 会先停掉旧的;并发发两条消息给同一 chat, 后者会打断前者。
  • 有一行调试 console.log 遗留。 resolveImageUrls 里打印了 base64 数据(services/agent.ts:896), 是明显的调试残留——这里如实指出,不美化。
  • 不含的内容: 各工具内部实现见 03-tools.md;系统提示文本如何拼(JSX→Markdown)见 04-system-prompt.md;可靠性/自托管见 05-reliability-deploy.md; 上下文即文件系统见 01-context-filesystem.md

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

主题文件路径符号名
装配总装线(造 agent)apps/backend/src/services/agent.tsAgentService.create
模型解析(默认回退链)apps/backend/src/services/agent.tsAgentService._getResolvedLlmSelectedModel
停止条件构造apps/backend/src/services/agent.tsstopWhen (create 内)
引擎壳 / 封 ToolLoopAgentapps/backend/src/services/agent.tsAgentManager 构造函数
每步预处理apps/backend/src/services/agent.tsAgentManager._prepareStep
交互流式 + 落库apps/backend/src/services/agent.tsAgentManager.stream / onFinish
非交互一把梭apps/backend/src/services/agent.tsAgentManager.generate
消息装配管线apps/backend/src/services/agent.tsAgentManager._buildModelMessages
故事同步去重apps/backend/src/services/agent.tsAgentManager._syncStoryToolOutputs
mention 注入公共基座apps/backend/src/services/agent.tsAgentManager._transformLastUserMessageText
剪枝apps/backend/src/services/agent.tsAgentManager._pruneMessages
图片 URL → base64apps/backend/src/services/agent.tsresolveImageUrls
系统提示交接点apps/backend/src/services/agent.tsAgentManager._buildSystemPrompt
记忆抽取调度apps/backend/src/services/agent.tsAgentManager._scheduleMemoryExtraction
标题生成旁路apps/backend/src/services/agent.tsAgentManager._generateTitle
provider → 模型实例apps/backend/src/agents/providers.tscreateProviderModel / LLM_PROVIDERS
提示缓存断点apps/backend/src/utils/prompt-cache.tsaddPromptCache / getPromptCacheProvider
预算闸门apps/backend/src/utils/budget.tsassertBudgetNotExceeded
工具带装配apps/backend/src/agents/tools/index.tsgetTools
web 搜索工具解析apps/backend/src/agents/tools/web-search.tscreateWebSearchTools
Fastify 流式入口apps/backend/src/routes/agent.tsagentRoutes
发车前胶水层apps/backend/src/handlers/agent.tshandleAgentRoute