)
1. 从 ReAct Agent 到 Claude-Code 式编码代理本地开发链路拆解LangGraph 是一个用状态机思路编排大模型应用的框架它把 Agent 的执行过程拆成节点和边让你能精确控制每一步给模型喂了什么上下文。ReAct Agent 是其中最经典的形态模型先思考、再决定调用哪个工具、拿到结果后继续推理直到给出最终答案。而 Claude-Code 式的编码代理本质上是在 ReAct 的基础上叠加了人工审查、子任务并发、任务清单跟踪、上下文压缩和实时中断这几层能力。这套东西适合谁适合已经能跑通单个工具调用、想进一步理解编码代理内部运转机制的开发者也适合想把本地脚本升级成可交互编码助手的同学。我这次要交付的是一条完整可跟做的链路从零搭一个最小 ReAct Agent然后逐步加上人工审查节点、SubAgent 并发、Todo 任务管理、8 段式上下文压缩最后用 TaoToken 统一 Key 把模型调用通道固定下来。整个过程在本地 Node.js 环境里跑不需要额外部署向量库或消息队列。你会拿到可复制的图结构定义、节点与边配置、工具注册代码以及环境变量和 Base URL 的设置方式。先说清楚一个前提LangGraph 的核心概念只有三个——State状态机、Node图节点、Tool工具。State 是一个带注解的对象定义了整个图在运行过程中携带哪些字段Node 是实际执行逻辑的函数接收 State 返回 State 的增量Tool 是暴露给模型的函数模型通过工具描述来决定是否调用。把这三件事理顺后面所有功能都是在这三者上做扩展。我试过直接读 Claude-Code 的逆向分析仓库信息量确实大提示词里还有前后矛盾的地方。所以下面我会按“功能描述 → 状态机变化 → 节点改造 → 工具实现”的顺序来组织每个环节都给出可运行的代码片段。你跟着敲一遍就能理解一个编码代理到底是怎么从最简单的 ReAct 长出来的。在开始之前先把项目初始化好。创建一个空目录执行npm init -y然后安装依赖npm install langchain/langgraph langchain/core langchain/openai zod这里用langchain/openai作为模型客户端因为 TaoToken 的 API 兼容 OpenAI 协议后面只需要改 Base URL 和 Key 就能切换通道。Zod 用来定义工具的参数 schemaLangGraph 的工具注册依赖它做参数校验。2. TaoToken 统一 Key 接入环境变量与 Base URL 配置在写第一行图代码之前先把模型调用通道固定下来。很多同学卡在第一步不是因为 LangGraph 难而是因为模型 Key 散落在各个脚本里换个模型就要改一堆文件。TaoToken 的做法是提供一个统一的 API 入口你只需要在环境变量里配一次 Base URL 和 Key后面所有模型调用都走这个通道。先到官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册账号然后在控制台里创建一个 API Key。控制台地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 进去之后左侧菜单找到 API Keys点新建复制生成的 Key。这个 Key 就是后面所有请求的凭证。拿到 Key 之后在项目根目录创建一个.env文件写入下面两行TAOTOKEN_API_KEYsk-你的实际Key TAOTOKEN_BASE_URLhttps://taotoken.net/api注意 Base URL 后面不要加/v1TaoToken 的 API 入口已经做了路径处理客户端库会自动补全。如果你用的是 OpenAI SDK 或其他兼容库直接把baseURL设成这个地址即可。然后在代码里读取环境变量初始化模型客户端import { ChatOpenAI } from langchain/openai; import dotenv from dotenv; dotenv.config(); const model new ChatOpenAI({ modelName: claude-3-5-sonnet-20241022, apiKey: process.env.TAOTOKEN_API_KEY, configuration: { baseURL: process.env.TAOTOKEN_BASE_URL, }, temperature: 0, });这里modelName可以换成你需要的模型 IDTaoToken 支持多种模型具体列表可以在模型对话页面查看https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 。如果你想先验证 Key 是否可用可以直接在模型对话页面发一条测试消息确认通道通了再写代码。如果你用的是 Claude Code 这类 CLI 工具配置方式略有不同。Claude Code 读取的是ANTHROPIC_BASE_URL和ANTHROPIC_API_KEY两个环境变量你可以在 shell 配置文件里加上export ANTHROPIC_BASE_URLhttps://taotoken.net/api export ANTHROPIC_API_KEYsk-你的实际Key这样 Claude Code 启动时就会走 TaoToken 通道。同理Codex 的auth.json里需要填base_url和api_key两个字段Cline 的 MCP 配置里需要填baseUrl、apiKey和model三件套。不管哪种工具核心都是这三样Base URL、Key、Model ID。配好之后写一个最小验证脚本const res await model.invoke(用一句话说明什么是状态机); console.log(res.content);如果终端能打印出模型回复说明通道已经通了。这一步很重要因为后面所有图节点都依赖这个模型实例如果这里报 401 或连接失败先排查 Key 和 Base URL不要急着往下写图。3. 可复制配置ReAct 图结构、节点与工具注册现在开始搭第一个 ReAct Agent。先定义状态机。LangGraph 用Annotation.Root来声明状态字段每个字段可以带 reducer 函数决定新值如何合并到旧值上。对于消息列表我们用messages字段reducer 用concat这样每次节点返回新消息时会追加而不是覆盖。import { Annotation } from langchain/langgraph; import { BaseMessage } from langchain/core/messages; const AgentState Annotation.Root({ messages: Annotation({ reducer: (x, y) x.concat(y), default: () [], }), });接下来定义工具。ReAct Agent 至少需要一个能执行外部动作的工具这里用一个简单的计算器和一个文件读取工具做示例。工具用tool函数包装参数用 Zod 定义import { tool } from langchain/core/tools; import { z } from zod; import fs from fs/promises; const calculator tool( async ({ expression }) { const result eval(expression); return 计算结果${result}; }, { name: calculator, description: 计算数学表达式输入一个合法的 JS 表达式字符串, schema: z.object({ expression: z.string().describe(要计算的表达式例如 23*4), }), } ); const readFile tool( async ({ path }) { const content await fs.readFile(path, utf-8); return content.slice(0, 2000); }, { name: read_file, description: 读取本地文件内容输入文件路径, schema: z.object({ path: z.string().describe(文件的绝对或相对路径), }), } ); const tools [calculator, readFile]; const toolsByName Object.fromEntries(tools.map((t) [t.name, t]));然后定义两个核心节点agent节点负责调用模型tools节点负责执行工具。agent节点把当前消息列表传给模型模型返回的消息可能带 tool_calls追加到状态里。tools节点遍历模型请求的工具调用逐个执行把结果包装成 ToolMessage 追加回去。import { ToolMessage } from langchain/core/messages; async function agentNode(state) { const response await model.bindTools(tools).invoke(state.messages); return { messages: [response] }; } async function toolNode(state) { const lastMessage state.messages[state.messages.length - 1]; const results []; for (const call of lastMessage.tool_calls) { const toolFn toolsByName[call.name]; const output await toolFn.invoke(call.args); results.push( new ToolMessage({ content: output, tool_call_id: call.id, }) ); } return { messages: results }; }接下来定义条件边如果模型最后一条消息里有tool_calls就路由到tools节点否则结束。这个判断函数叫shouldContinuefunction shouldContinue(state) { const lastMessage state.messages[state.messages.length - 1]; if (lastMessage.tool_calls lastMessage.tool_calls.length 0) { return tools; } return __end__; }最后组装图import { StateGraph, START, END } from langchain/langgraph; const workflow new StateGraph(AgentState) .addNode(agent, agentNode) .addNode(tools, toolNode) .addEdge(START, agent) .addConditionalEdges(agent, shouldContinue, { tools: tools, __end__: END, }) .addEdge(tools, agent); const app workflow.compile();运行一下const result await app.invoke({ messages: [{ role: user, content: 帮我算一下 128 乘以 37然后读取 package.json 的前几行 }], }); console.log(result.messages[result.messages.length - 1].content);如果模型正确调用了两个工具并汇总了结果说明最小 ReAct Agent 已经跑通。这个图的结构很简单但它是后面所有扩展的基础。你可以把这段代码保存成react-agent.js后面每加一个功能就在这个文件上改。4. 验证请求与成功结果人工审查、SubAgent 与 Todo 任务管理最小 ReAct 跑通之后开始加第一层能力人工审查。Claude-Code 在需要读写文件时会询问用户权限这个机制在 LangGraph 里用interrupt和Command两个 API 实现。interrupt会在节点内部抛出一个中断信号把当前状态存进检查点等待外部恢复Command用来在恢复时动态指定下一个节点。先加一个human_review节点import { interrupt, Command } from langchain/langgraph; async function humanReviewNode(state) { const lastMessage state.messages[state.messages.length - 1]; const decision interrupt({ question: 是否批准执行以下工具调用, toolCalls: lastMessage.tool_calls, }); if (decision approve) { return new Command({ goto: tools }); } return new Command({ goto: agent }); }然后在shouldContinue里把路由目标改成human_reviewfunction shouldContinue(state) { const lastMessage state.messages[state.messages.length - 1]; if (lastMessage.tool_calls lastMessage.tool_calls.length 0) { return human_review; } return __end__; }图定义里加上节点和边const workflow new StateGraph(AgentState) .addNode(agent, agentNode) .addNode(human_review, humanReviewNode) .addNode(tools, toolNode) .addEdge(START, agent) .addConditionalEdges(agent, shouldContinue, { human_review: human_review, __end__: END, }) .addEdge(human_review, tools) .addEdge(tools, agent);编译时需要传入 checkpointer否则 interrupt 无法保存状态import { MemorySaver } from langchain/langgraph; const checkpointer new MemorySaver(); const app workflow.compile({ checkpointer });调用时用configurable传入 thread_idconst config { configurable: { thread_id: session-1 } }; const result await app.invoke({ messages: [...] }, config);如果触发中断result里会包含__interrupt__字段。恢复时用Command传入决策await app.invoke(new Command({ resume: approve }), config);接下来加 SubAgent 并发。思路是把 Task 工具暴露给主 AgentTask 工具内部创建一个独立的子图实例用独立的 message 队列运行最后把结果合成回主 Agent。先定义子 Agent 的配置const subAgentConfigs [ { name: researcher, description: 负责检索和整理信息, tools: [readFile] }, { name: coder, description: 负责生成和修改代码, tools: [calculator] }, ];Task 工具的核心是创建子图并调用const taskTool tool( async ({ description, agentName }) { const config subAgentConfigs.find((c) c.name agentName); const subModel model.bindTools(config.tools); const subResult await subModel.invoke([ { role: system, content: 你是${config.description} }, { role: user, content: description }, ]); return subResult.content; }, { name: task, description: 创建一个子 Agent 处理独立任务输入任务描述和 Agent 名称, schema: z.object({ description: z.string(), agentName: z.string(), }), } );把taskTool加入主 Agent 的工具列表。并发执行时LangGraph 的 ToolNode 天然支持同一节点内多个工具调用并行但如果你想控制并发度可以把工具分成并发安全和并发不安全两组放在不同的 ToolNode 里。这里先不做细分保持简单。Todo 任务管理相对直接。先在状态机里加一个todoList字段const AgentState Annotation.Root({ messages: Annotation({ reducer: (x, y) x.concat(y), default: () [], }), todoList: Annotation({ reducer: (x, y) y, default: () [], }), });然后定义 TodoWrite 和 TodoRead 两个工具const todoWrite tool( async ({ todos }) { return JSON.stringify(todos); }, { name: todo_write, description: 更新当前会话的任务列表主动使用此工具跟踪进度, schema: z.object({ todos: z.array( z.object({ content: z.string(), status: z.enum([pending, in_progress, completed]), }) ), }), } ); const todoRead tool( async () { return 请从状态中读取当前任务列表; }, { name: todo_read, description: 读取当前任务列表状态尽可能频繁地使用此工具, schema: z.object({}), } );在toolNode里对todo_write做特殊处理把结果写回状态async function toolNode(state) { const lastMessage state.messages[state.messages.length - 1]; const results []; let newTodoList state.todoList; for (const call of lastMessage.tool_calls) { if (call.name todo_write) { newTodoList call.args.todos; } const toolFn toolsByName[call.name]; const output await toolFn.invoke(call.args); results.push(new ToolMessage({ content: output, tool_call_id: call.id })); } return { messages: results, todoList: newTodoList }; }这样模型就能通过工具调用自主管理任务清单主 Agent 的系统提示词里加上一句“在开始复杂任务前先调用 todo_write 规划步骤”效果会明显提升。5. 本篇常见错排查401、local proxy failed 与 reading choices跑这套链路时最容易卡住的不是图逻辑而是模型调用通道。下面按真实报错逐个排查。401 Unauthorized这个报错说明 Key 没被正确读取。先检查.env文件是否在项目根目录dotenv.config()是否在模型初始化之前调用。然后确认TAOTOKEN_API_KEY的值没有多余空格或引号。如果用的是 Claude Code检查ANTHROPIC_API_KEY是否导出到了当前 shell可以用echo $ANTHROPIC_API_KEY验证。还有一种情况是 Key 被撤销或过期去控制台重新生成一个即可。local proxy failed / connection refused这个报错通常出现在 Base URL 写错的时候。确认TAOTOKEN_BASE_URL是https://taotoken.net/api末尾不要加/v1也不要加斜杠。如果你在代码里手动拼了/v1/chat/completions去掉它客户端库会自动补全路径。另外检查本地网络是否能正常访问该地址可以用curl https://taotoken.net/api测试连通性。reading choices of undefined这个报错说明模型返回的响应结构不符合预期通常是 Base URL 指向了一个不兼容 OpenAI 协议的端点。TaoToken 的 API 兼容 OpenAI 格式响应里应该有choices数组。如果报这个错先打印完整响应体看看返回了什么。常见原因是modelName填了一个不存在的模型 ID服务端返回了错误信息而不是标准响应。去模型对话页面确认可用的模型 ID填对之后重新请求。OAuth 相关报错如果你用的是 Claude Code 或 Codex 这类 CLI可能会遇到 OAuth token 过期或未授权。这类工具优先读取环境变量里的 API Key如果环境变量没配才会走 OAuth 流程。确保ANTHROPIC_API_KEY或auth.json里的api_key已经填好就不会触发 OAuth。Codex 的auth.json路径通常在~/.codex/auth.json内容格式是{ base_url: https://taotoken.net/api, api_key: sk-你的实际Key }工具调用参数校验失败如果模型返回的 tool_calls 参数不符合 Zod schema工具执行会抛错。检查工具描述是否清晰参数名是否和 schema 一致。可以在toolNode里加 try-catch把错误信息作为 ToolMessage 返回给模型让它自己修正。interrupt 恢复后状态丢失确认编译图时传入了 checkpointer并且每次调用都带同一个thread_id。如果换了 thread_id检查点找不到恢复就会从头开始。排障的核心思路是先确认通道通不通再确认图逻辑对不对。通道问题看 Key 和 Base URL图逻辑问题看状态字段和边路由。把这两层分开排查大部分报错都能快速定位。6. 语义一致 CTA把统一 Key 通道固定到你的编码工作流走到这里你已经有了一个能跑通 ReAct、人工审查、SubAgent 并发和 Todo 任务管理的本地编码代理。下一步是把它变成日常可用的工具。最直接的做法是把模型调用通道固定成 TaoToken 的统一 Key这样换模型、换项目都不用改代码。如果你主要在终端里做编码建议把 Claude Code 接上 TaoToken 通道配置方式就是前面说的两个环境变量。配好之后Claude Code 的所有请求都会走统一入口你可以在控制台里看到调用记录和用量。控制台地址再放一次https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。如果你更习惯用 API 直接调去 API Keys 页面生成一个长期 Keyhttps://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。生成之后填进.env后面所有脚本共用这一个 Key。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里面有各语言客户端的配置示例遇到路径或参数问题可以对照查。如果你打算把这套 Agent 长期跑在编码任务上比如让它自动处理 issue、生成 PR 或者做代码审查可以考虑 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 。它适合需要持续调用、对额度和稳定性有要求的场景。先用模型对话页面验证通道https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 确认模型可用之后再接入到你的图里。最后给一个实用技巧把thread_id和项目目录绑定比如用path.basename(process.cwd())作为 thread_id 的一部分这样每个项目的检查点互相隔离恢复时不会串状态。另外在toolNode里加一层日志把每次工具调用的名称和参数打到文件里排查问题时比翻控制台输出快得多。这套链路我跑下来最耗时的部分其实是调工具描述和提示词图结构本身改动很少。把提示词写清楚模型的工具调用准确率会高很多。