拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

我们能从 Claude Code 源码里学到什么:1. 拆解 Agent 主循环 query.ts

我们能从 Claude Code 源码里学到什么:1. 拆解 Agent 主循环 query.ts

1. 为什么值得花时间读 query.ts

如果你写过 CRUD,第一次看 Agent 源码大概率会懵:控制流不在自己手里,而是交给了一个输出带随机性的大模型。Claude Code 的src/query.ts大约两千行,把 ReAct(Reasoning + Acting)主循环完整实现了一遍,是整个编程助手的调度中枢。它解决的问题很具体:模型可能中途报错、可能输出被截断、可能上下文爆掉、可能用户按了 Ctrl+C,而外层 UI 还得保持响应。想理解 Agent 到底怎么跑起来,与其看十篇概念文章,不如把这一条主循环拆开对照着跑一遍。

这篇面向已经会写后端、想搞懂 Agent 运行机制的开发者。我会先讲清楚queryLoop的状态流转和几个关键设计,再给出一份可复制的最小循环骨架,最后用 TaoToken 的 API 在本地把一次 ReAct 循环真正跑通。你不需要读完两千行源码,但跟着走完,能对「不确定的大脑 + 确定的工程外壳」这件事有体感。

2. 拆解 queryLoop:不可变状态与生成器

2.1 状态不是全局变量,而是每轮重建

传统后端习惯用一个对象记录进度,原地改字段。Claude Code 没这么做。它把状态定义成一个不可变结构,每次continue进入下一轮,都构造一个全新的 State:

type State = { messages: Message[] // 当前对话历史 toolUseContext: ToolUseContext // 工具执行上下文 turnCount: number // 当前轮次 transition: Continue | undefined // 上一次进入新循环的原因 }

关键在于transition。它显式记录了「为什么又转了一圈」——是模型返回了工具调用,还是工具执行完要回灌结果,还是触发了错误恢复。Agent 出问题时最怕状态改到一半留下脏数据,不可变设计让每一轮的状态都是完整快照,排查时能顺着 transition 的轨迹看清流转路径。

2.2 async generator 带来的背压与中断

主函数用的是async function*,也就是异步生成器。底层不等模型把整段话生成完,而是通过yield message逐块把事件抛给外层 UI。这带来两个直接好处:一是流式渲染天然支持,用户能边看边等;二是中途拦截变得干净——用户按 Ctrl+C,外层直接中止生成器,底层逻辑随之退出,不需要额外的取消标志位到处传递。

我试过把这种模式套到自己的小工具上,最直观的感受是:把「循环推进」和「事件消费」解耦之后,UI 层完全不用关心 Agent 内部跑到第几步。

2.3 五层上下文压缩流水线

Agent 开发者必须抠 Context Window,因为上下文又贵又容易让模型注意力涣散。query.ts在每次调 API 前会跑一条压缩流水线,按顺序是:

层级手段作用
1工具输出预算超长输出写磁盘,历史里只留摘要和路径
2历史截断保留首尾,裁掉中间冗长记录
3细粒度缓存压缩借 Prompt Cache 删除不再重要的旧工具结果
4上下文折叠本地保留完整消息,发给 API 的替换为摘要
5自动摘要压缩逼近窗口红线时,用小模型把前文浓缩

第 4 层最值得学:它做的是「视图投影」。本地内存里原始消息一条不少,用户翻历史能看到全部;但发给模型的是折叠后的精简版。省了 API 成本,又没牺牲可读性。

2.4 流式工具执行与静默纠错

普通工具调用框架是线性的:等模型输出完整 JSON 数组,解析,再执行。Claude Code 用了流式执行器,模型通过 SSE 逐字生成,当工具 A 的 JSON 刚闭合(}出现)的那一瞬间,就把它丢进后台线程开始跑,此时模型还在生成工具 B 的参数。API 生成时间和本地 IO 时间重叠,等待感被大幅削掉。

错误恢复同样硬核。遇到max-output-tokens被硬截断时,它不会让用户手动输入「继续」,而是静默注入一条伪造的用户消息:

const recoveryMessage = createUserMessage({ content: 'Output token limit hit. Resume directly — no apology, no recap. Pick up mid-thought.', isMeta: true, })

这种静默接续最多允许 3 次,超过才熔断。另外还有个细节:所有要抛给前端的修饰内容,只在深拷贝的inputCopy上改,真正压入messagesForQuery的永远是原始数据——因为 Anthropic 的缓存匹配是字节级严格匹配,历史对象里多一个临时字段就会击穿整个前缀缓存。

3. 用 TaoToken 搭一个最小可跑环境

理解了机制,接下来动手。我们要在本地复现一次最小 ReAct 主循环:模型推理 → 决定调用工具 → 执行工具 → 结果回灌 → 再推理,直到给出最终答案。模型调用走 TaoToken,它兼容 Anthropic 风格的接口,接入成本低。

3.1 准备 API Key

打开 TaoToken 控制台,在 API Keys 页面创建一个密钥。建议按项目分 Key,方便后面看用量。创建后复制保存,页面只显示一次。

3.2 配置环境变量

不要把手写的 Key 硬编码进代码。用环境变量:

export TAOTOKEN_API_KEY="sk-你的密钥" export TAOTOKEN_BASE_URL="https://taotoken.net/api"

TAOTOKEN_BASE_URL指向 API 根地址,注意这里不带任何查询参数。如果你用 Node,可以配合dotenv从.env读取,避免每次开终端都 export。

3.3 安装依赖

npm init -y npm install @anthropic-ai/sdk

SDK 支持自定义 baseURL,正好用来指向 TaoToken。装完确认package.json里"type": "module",下面用 ESM 写。

4. 可复制的最小 ReAct 主循环骨架

4.1 定义工具与状态

先定义两个最简单的工具,一个算加法,一个读文件,用来观察循环怎么在「推理」和「行动」之间切换:

import Anthropic from '@anthropic-ai/sdk' import { readFileSync } from 'node:fs' const client = new Anthropic({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: process.env.TAOTOKEN_BASE_URL, }) const tools = [ { name: 'add', description: '计算两个数字之和', input_schema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } }, required: ['a', 'b'], }, }, { name: 'read_file', description: '读取指定路径的文本文件', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], }, }, ]

状态用不可变思路管理,每轮构造新对象,和query.ts保持一致:

type LoopState = { messages: any[] turnCount: number transition?: string }

4.2 工具执行器

工具执行单独抽出来,方便后面加超时和错误处理:

function runTool(name: string, input: any): string { if (name === 'add') return String(input.a + input.b) if (name === 'read_file') { try { return readFileSync(input.path, 'utf-8').slice(0, 2000) } catch (e) { return `读取失败: ${(e as Error).message}` } } return `未知工具: ${name}` }

4.3 主循环

核心循环就是 ReAct 的骨架:调模型 → 看有没有工具调用 → 有就执行并回灌 → 没有就结束。加一个最大轮次防止死循环:

async function queryLoop(userInput: string, maxTurns = 8) { let state: LoopState = { messages: [{ role: 'user', content: userInput }], turnCount: 0, transition: 'init', } while (state.turnCount < maxTurns) { const res = await client.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 1024, tools, messages: state.messages, }) // 把模型这一轮输出压入历史 state = { ...state, messages: [...state.messages, { role: 'assistant', content: res.content }], turnCount: state.turnCount + 1, transition: 'assistant_reply', } const toolUses = res.content.filter((b: any) => b.type === 'tool_use') if (toolUses.length === 0) { const text = res.content.find((b: any) => b.type === 'text') console.log('最终回答:', text?.text) return } // 执行工具,回灌结果 const toolResults = toolUses.map((tu: any) => ({ type: 'tool_result', tool_use_id: tu.id, content: runTool(tu.name, tu.input), })) state = { ...state, messages: [...state.messages, { role: 'user', content: toolResults }], transition: 'tool_result', } console.log(`第 ${state.turnCount} 轮,执行了 ${toolUses.length} 个工具`) } console.log('达到最大轮次,退出') } queryLoop('帮我算一下 128 加 256,然后读一下 ./package.json 的前几行')

注意每次continue都是{ ...state, ... }重建,而不是state.messages.push(...)。这就是从源码里学到的第一件事:状态流转可追踪,比省那点内存重要得多。

5. 验证请求与成功结果

5.1 跑起来看输出

node loop.mjs

正常的话你会看到类似这样的过程:

第 1 轮,执行了 2 个工具 最终回答: 128 加 256 等于 384。package.json 的前几行显示这是一个 ESM 项目...

第一轮模型同时决定调用add和read_file,两个工具结果回灌后,第二轮模型整合信息给出最终回答,循环结束。这说明 ReAct 主循环跑通了:推理和行动交替,直到不再需要工具。

5.2 观察状态流转

把transition打印出来,你会看到init → assistant_reply → tool_result → assistant_reply的轨迹。这正是query.ts里transition.reason想给你的东西——出问题时,你能一眼看出卡在哪一步。如果模型一直调工具不收敛,maxTurns会兜底退出,不会无限烧 token。

5.3 换成流式输出

想更接近 Claude Code 的体验,把messages.create换成messages.stream,逐块打印文本。这样你能直观看到「模型还在生成工具 B 参数时,工具 A 已经在执行」的流水线效果——虽然最小骨架里是串行执行,但理解了这个时间重叠,你就明白流式执行器为什么能省等待。

6. 本篇常见错排查

报 401 或鉴权失败:先确认TAOTOKEN_API_KEY真的被读到了,echo $TAOTOKEN_API_KEY看有没有值。常见坑是.env没加载,或者 Key 复制时带了空格。

报模型不存在:model字段要填当前可用的模型名。如果拿不准,去 模型对话 页面确认一下可用列表,再回填到代码里。

工具调用死循环:模型反复调同一个工具,通常是工具返回内容里带了让它误解的信息。检查runTool的返回值,别把错误堆栈原样丢回去,改成简短描述。同时保留maxTurns兜底。

上下文越来越长导致变慢:这就是源码里五层压缩要解决的问题。最小骨架没做压缩,长对话会明显变慢变贵。生产环境至少要加一层「工具输出截断」,把超长结果写文件、只回传摘要和路径。

baseURL 写错:baseURL只填https://taotoken.net/api,不要带/v1或查询参数,SDK 会自己拼路径。写错通常表现为 404。

7. 下一步:从骨架到工业级

跑通最小循环只是起点。真正把 Agent 做稳,要补的正是query.ts里那些工程细节:不可变状态、多级压缩、流式并发、静默纠错。如果你打算长期写 Agent 或做编码类工具,建议直接上 Coding Plan,把额度管理和模型调度交给平台,自己专注在循环逻辑上。接入细节和参数说明可以对照 接入文档,遇到报错先查文档再调代码,能省不少时间。

把上面这份骨架存下来,改改工具定义,你就能拿它试各种 ReAct 场景。等哪天你的循环开始出现「模型不收敛」「上下文爆炸」这些真实问题,再回头读query.ts,会发现那些设计不是炫技,而是被这些问题逼出来的。

返回列表