
1. 为什么值得花时间读 query.ts如果你写过 CRUD第一次看 Agent 源码大概率会懵控制流不在自己手里而是交给了一个输出带随机性的大模型。Claude Code 的src/query.ts大约两千行把 ReActReasoning Acting主循环完整实现了一遍是整个编程助手的调度中枢。它解决的问题很具体模型可能中途报错、可能输出被截断、可能上下文爆掉、可能用户按了 CtrlC而外层 UI 还得保持响应。想理解 Agent 到底怎么跑起来与其看十篇概念文章不如把这一条主循环拆开对照着跑一遍。这篇面向已经会写后端、想搞懂 Agent 运行机制的开发者。我会先讲清楚queryLoop的状态流转和几个关键设计再给出一份可复制的最小循环骨架最后用 TaoToken 的 API 在本地把一次 ReAct 循环真正跑通。你不需要读完两千行源码但跟着走完能对「不确定的大脑 确定的工程外壳」这件事有体感。2. 拆解 queryLoop不可变状态与生成器2.1 状态不是全局变量而是每轮重建传统后端习惯用一个对象记录进度原地改字段。Claude Code 没这么做。它把状态定义成一个不可变结构每次continue进入下一轮都构造一个全新的 Statetype State { messages: Message[] // 当前对话历史 toolUseContext: ToolUseContext // 工具执行上下文 turnCount: number // 当前轮次 transition: Continue | undefined // 上一次进入新循环的原因 }关键在于transition。它显式记录了「为什么又转了一圈」——是模型返回了工具调用还是工具执行完要回灌结果还是触发了错误恢复。Agent 出问题时最怕状态改到一半留下脏数据不可变设计让每一轮的状态都是完整快照排查时能顺着 transition 的轨迹看清流转路径。2.2 async generator 带来的背压与中断主函数用的是async function*也就是异步生成器。底层不等模型把整段话生成完而是通过yield message逐块把事件抛给外层 UI。这带来两个直接好处一是流式渲染天然支持用户能边看边等二是中途拦截变得干净——用户按 CtrlC外层直接中止生成器底层逻辑随之退出不需要额外的取消标志位到处传递。我试过把这种模式套到自己的小工具上最直观的感受是把「循环推进」和「事件消费」解耦之后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_KEYsk-你的密钥 export TAOTOKEN_BASE_URLhttps://taotoken.net/apiTAOTOKEN_BASE_URL指向 API 根地址注意这里不带任何查询参数。如果你用 Node可以配合dotenv从.env读取避免每次开终端都 export。3.3 安装依赖npm init -y npm install anthropic-ai/sdkSDK 支持自定义 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会发现那些设计不是炫技而是被这些问题逼出来的。