在实际的 AI 应用开发中Agent智能体这个词已经被反复使用但很多开发者对它的理解停留在“调用大模型 API 再拼接提示词”的层面。真正复杂的点在于大模型如何决定调用哪个工具、工具结果如何回传给模型、循环什么时候结束、中间出错怎么恢复。这些问题用现成框架例如 Dify、Coze、LangGraph虽然能解决但框架会屏蔽底层机制一旦项目需要定制循环逻辑排查问题就会非常吃力。这篇文章的目标是用 TypeScript 手写一个通用智能体的最小运行时。所谓“通用”是指不绑定具体业务用户可以自由注册任意工具Agent 根据用户输入自动决定工具调用顺序并完成回答。整个核心循环只有 100 行左右但消息协议、工具注册、LLM 抽象、错误回传这些生产级 Agent 的关键设计都会覆盖到。学完之后再看 LangGraph 或自研 Agent 框架的源码会轻松很多。整篇文章围绕一条主线展开让一个没有外部数据源的大模型通过工具调用获得实时信息最终给出可信的回答。会先拆解 Agent 的工作原理再逐层实现类型定义、模型提供方、工具调度和主循环最后给出完整的运行示例、常见问题排查和扩展方向。1. Agent 到底是什么先拆清楚一个智能体的运行闭环1.1 通俗理解把大模型从“聊天对话框”变成“能办事的助手”普通的大模型调用本质是一次文本到文本的映射用户输入一段话模型输出一段话。这个模式适合写文案、翻译、做总结但模型无法获取实时天气、查不到数据库里的订单、改不了服务器上的文件因为模型自身没有“手”和“脚”。Agent 的定位就是在模型和外部世界之间搭一座桥。它不改变模型本身而是给模型增加两项能力模型可以输出“我希望调用某个工具”的结构化指令而不是只能输出文本。Agent 运行时负责执行这个指令并把执行结果返回给模型。从用户视角看Agent 仍然是一个问答入口但背后多了一条“思考-行动-观察”的循环链路。一个典型例子用户问“北京现在适合穿什么衣服”普通模型只能凭训练数据猜测Agent 会先调用天气查询工具拿到真实温度、风力、空气质量后再结合这些数据给出穿衣建议。1.2 Agent 的经典工作循环思考、调用工具、观察结果、再思考当前主流 Agent 大多遵循 ReActReasoning Acting模式。核心循环可以描述为以下步骤接收用户输入组装消息列表。把消息列表和已注册的工具定义一起发送给大模型。大模型有两种可能的返回返回最终答案循环结束。返回一个或多个工具调用指令包括工具名和参数。Agent 执行工具把结果包装成一条“工具消息”追加到消息列表。回到第 2 步让模型看到工具结果后继续推理。可以用一个生活类比来理解你让同事去查一份报表。同事如果知道你手头有这份数据他会直接翻阅如果不知道他会先问你“报表放在哪里”拿到你给的路径后再去查最后把查询结果整理成结论告诉你。Agent 循环中的“工具调用指令”就相当于同事提出的问题“工具消息”就相当于你给出的数据。1.3 为什么花 100 行手写而不是直接上框架现成的 Agent 平台和编排框架很多它们确实能缩短开发时间但也带来了三个问题循环逻辑被封装模型为什么不调用工具、为什么多轮循环之后才出结果调试困难。框架的抽象层级较高想定制“先规划再执行”“多角色协作”这类逻辑只能学习框架内部机制。框架升级后行为可能变化排错资料又少容易卡住。手写一个最小 Agent 的价值不在于替代框架而在于把下面这张表里的差异看清楚维度普通 LLM 调用Agent 循环输入用户文本用户文本 系统提示 历史消息 工具定义模型输出只有文本可能是文本也可能是结构化工具调用指令外部数据模型无法获取通过工具执行获得终止条件单次响应结束可能多轮迭代直到模型返回最终答案错误处理一次请求失败就失败工具执行失败可以把错误回传让模型自行修正可扩展性改提示词注册新工具即可扩展能力边界理解了这些差异后面实现代码时每一步需要解决什么问题都会非常明确。2. 设计一个最小通用 Agent先定义数据结构和模块边界2.1 Agent 的四类核心对象消息、工具、模型提供方、循环控制器在真正写循环之前先把模块边界划清楚。一个可扩展的 Agent 运行时至少要包含四个对象消息Message会话中传递的信息单元需要区分角色还要能表达工具调用关系。工具Tool由“描述信息”和“执行函数”组成描述信息给模型看执行函数给运行时用。模型提供方LLMProvider屏蔽不同模型服务商的接口差异统一暴露一个 chat 方法。循环控制器Agent负责维护消息列表、调用模型、解析工具指令、执行工具、判断退出条件。这种分层设计不是过度设计。Agent 循环是所有业务的基础模型服务商可能换工具列表可能变但“循环逻辑”是稳定的。把易变的部分抽象出去核心循环就不需要频繁改动。2.2 用 TypeScript 定义类型让协议错误在编译期暴露先创建src/types.ts把核心类型定下来// src/types.ts export type Role system | user | assistant | tool; export interface ToolCall { id: string; type: function; function: { name: string; arguments: string; }; } export interface Message { role: Role; content: string; name?: string; toolCallId?: string; toolCalls?: ToolCall[]; } export interface ToolDefinition { name: string; description: string; parameters: Recordstring, unknown; required?: string[]; } export interface Tool { definition: ToolDefinition; execute: (args: Recordstring, unknown) Promisestring; } export interface ChatResult { content?: string; toolCalls?: ToolCall[]; } export interface LLMProvider { chat(messages: Message[], tools: ToolDefinition[]): PromiseChatResult; }这些类型中有几个字段是初学者最容易忽略的需要重点说明Message.toolCallId当角色是tool时必须用它来关联上一条 assistant 消息中的工具调用。这是 OpenAI 兼容协议规定的消息对应关系没有这个字段模型无法知道这条工具结果对应的是哪一次调用。Message.toolCalls当角色是assistant时模型返回的工具调用列表需要完整保留在消息历史里。如果历史里丢了这些信息模型在下一轮就无法引用前一轮的调用。ToolDefinition.parameters这里的结构是 JSON Schema 中的properties部分。传给模型时Provider 会包装成完整的{ type: object, properties, required }结构。2.3 为什么选择 TypeScript类型、生态、调试选择 TypeScript 不只是因为标题需要。实际开发中Agent 的消息协议和工具协议非常复杂类型系统能提前避免大量字段拼写错误例如tool_call_id写成toolCallId、function.arguments传成对象而不是 JSON 字符串。这些错误在 JavaScript 里要等到运行时 400 报错才能发现在 TypeScript 里编译阶段就能暴露。同时Node.js 18 及以上版本内置了fetch实现 LLM Provider 不需要额外引入 HTTP 客户端。这让整个项目保持轻量核心逻辑集中在一个文件里适合用来讲解原理。注意本项目的类型定义面向“OpenAI Chat Completions 兼容协议”。不同平台对协议会有细微差异落地时要以实际服务商文档为准。3. 写一个可切换的 LLM 提供商抽象层3.1 为什么必须隔离模型层如果 Agent 直接写死一家模型服务换模型就必须改业务代码工具调用的序列化格式也得跟着改。把 LLM 调用抽象成LLMProvider接口后上层 Agent 只依赖接口不依赖具体实现。换模型时只需要新增一个 Provider 类。当前国内外大多数模型服务商都提供 OpenAI 兼容接口也就是说只要实现了 Chat Completions 协议就能对接大量服务。这个抽象层在工程里的价值是上线初期接一家模型后续有更好选择时改动面被限制在 Provider 实现内部。3.2 实现一个 OpenAI 兼容 Provider创建src/provider.ts使用 Node.js 内置fetch实现协议调用// src/provider.ts import { LLMProvider, Message, ToolDefinition, ToolCall, ChatResult } from ./types; export interface ProviderOptions { apiKey: string; baseUrl: string; model: string; temperature?: number; maxTokens?: number; } export class OpenAICompatibleProvider implements LLMProvider { private apiKey: string; private baseUrl: string; private model: string; private temperature: number; private maxTokens: number; constructor(opts: ProviderOptions) { this.apiKey opts.apiKey; this.baseUrl opts.baseUrl.replace(/\/$/, ); this.model opts.model; this.temperature opts.temperature ?? 0.7; this.maxTokens opts.maxTokens ?? 1024; } async chat(messages: Message[], tools: ToolDefinition[]): PromiseChatResult { const payload: Recordstring, unknown { model: this.model, messages: messages.map((m) { const base: Recordstring, unknown { role: m.role, content: m.content, }; if (m.name) base.name m.name; if (m.toolCallId) base.tool_call_id m.toolCallId; if (m.toolCalls) base.tool_calls m.toolCalls; return base; }), temperature: this.temperature, max_tokens: this.maxTokens, }; if (tools.length 0) { payload.tools tools.map((t) ({ type: function, function: { name: t.name, description: t.description, parameters: { type: object, properties: t.parameters, required: t.required ?? [], }, }, })); } const res await fetch(${this.baseUrl}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.apiKey}, }, body: JSON.stringify(payload), }); if (!res.ok) { const text await res.text(); throw new Error(LLM 请求失败状态 ${res.status}响应 ${text}); } const data await res.json(); const choice data.choices?.[0]; const rawMessage choice?.message ?? {}; const toolCalls: ToolCall[] (rawMessage.tool_calls ?? []).map( (tc: Recordstring, unknown) ({ id: tc.id as string, type: function, function: { name: (tc.function as { name?: string })?.name ?? , arguments: (tc.function as { arguments?: string })?.arguments ?? {}, }, }) ); return { content: rawMessage.content ?? undefined, toolCalls, }; } }这个 Provider 的主要工作有三块把内部Message类型转换成协议要求的 JSON 结构重点是把toolCallId映射成tool_call_id。把工具定义包装成协议要求的tools数组parameters必须是完整 JSON Schema。解析响应中的tool_calls并把arguments保持为 JSON 字符串不提前JSON.parse。原因后面工具执行部分会说明。3.3 核心参数说明temperature和max_tokens是最常见的两个参数理解它们的调优效果有助于在 Agent 场景里选择合适的值。参数含义默认值调大影响调小影响temperature采样随机性0.7回答更有创意但也更容易偏离工具调用格式输出更稳定但可能过于机械max_tokens单次回复最大 token 数1024支持更长回答或更多工具调用可能截断 tool_calls 或最终答案tools传给模型的工具定义列表无模型可选项变多模型选择更集中减少误用实际开发中如果 Agent 主要执行工具调用类任务可以把temperature调低到 0.2 或 0.3。因为工具调用是确定性的动作模型需要严格遵守 JSON 结构过度随机会增加参数格式错误率。如果 Agent 要承担生成类任务写文章、做总结则维持 0.7 左右更合适。3.4 常见错误为什么接口报 400OpenAI 兼容接口最常见的 400 报错来自tools参数格式不合法典型错误包括parameters没有properties字段直接传了空对象。arguments字段被误传成对象而不是字符串。assistant消息携带了tool_calls但下一条tool消息缺少tool_call_id。排查这类问题最有效的方式是把实际发送的payload打印出来逐字段比对协议文档。后面第 7 章会给出完整的排查链路。4. 实现工具注册与调度让 Agent 真正拥有“手和脚”4.1 工具的本质给模型看说明书给运行时看执行函数工具注册不是简单地把一个函数放进来。从模型的角度看它读不懂 JavaScript 函数它能理解的是“工具说明书”也就是name、description、parameters这些 JSON Schema 信息。大模型根据说明书决定“什么时候用、用什么参数”。从运行时的角度看真正被执行的是execute函数。因此一个工具必须同时包含这两部分。description写得好不好直接影响模型是否会在正确场景调用工具。例如get_weather的描述如果只写“查询天气”模型在用户问“明天去杭州旅游穿什么”时可能想不到调用它如果写成“查询指定城市的当前天气适合回答关于天气、穿衣、出行的问题”模型会更愿意调用。4.2 注册两个最小工具天气查询和当前时间创建src/tools.ts以 Map 形式管理工具键是工具名值是工具对象// src/tools.ts import { Tool } from ./types; export function createDefaultTools(): Mapstring, Tool { const tools new Mapstring, Tool(); tools.set(get_weather, { definition: { name: get_weather, description: 查询指定城市的当前天气适合回答关于天气、穿衣、出行的问题, parameters: { city: { type: string, description: 城市名例如北京、上海 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位默认 celsius, }, }, required: [city], }, execute: async (args) { const city String(args.city ?? 未知城市); const unit args.unit fahrenheit ? 华氏度 : 摄氏度; return JSON.stringify({ city, weather: 晴, temperature: unit 华氏度 ? 77°F : 25°C, humidity: 40%, wind: 东南风3级, note: 真实项目中这里应调用天气服务 API并做好超时和降级处理, }); }, }); tools.set(get_current_time, { definition: { name: get_current_time, description: 获取当前日期和时间适合回答现在几点、今天几号等问题, parameters: {}, required: [], }, execute: async () { return JSON.stringify({ datetime: new Date().toISOString(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); }, }); return tools; }这个示例里的模拟 weather 数据只是为了跑通循环。真实项目中execute内部可以调用 HTTP API、查询数据库、读写文件只要最终返回一个字符串即可。推荐统一返回 JSON 字符串因为结构化数据更容易让模型理解。4.3 工具调用执行参数解析、找不到工具、异常都不能让循环崩掉工具执行是 Agent 循环中最容易出现运行时错误的位置处理原则是工具失败不等于 Agent 失败。正确做法是把错误信息以字符串形式返回给模型让模型自己决定是修正参数再次调用还是告诉用户出错原因。在src/agent.ts中实现runTool方法// src/agent.ts 内部 private async runTool(call: ToolCall): Promisestring { let args: Recordstring, unknown; try { args JSON.parse(call.function.arguments || {}); } catch (err) { return 工具参数不是合法 JSON${err instanceof Error ? err.message : String(err)}; } const tool this.tools.get(call.function.name); if (!tool) { return 找不到工具${call.function.name}; } try { const result await tool.execute(args); return typeof result string ? result : JSON.stringify(result); } catch (err) { return 工具执行失败${err instanceof Error ? err.message : String(err)}; } }这里有三个关键判断模型返回的arguments是 JSON 字符串解析失败时把错误信息返回给模型而不是直接抛异常因为模型在下一轮可以根据错误信息修正参数。模型可能产生幻觉调用一个根本不存在的工具。此时把“找不到工具”返回给模型比直接中断循环更能体面收场。execute内部抛出的异常同样捕获成字符串。这在工具访问外部服务时尤其重要例如天气 API 超时模型看到超时信息后可以向用户如实说明也可以选择稍后重试。注意把错误信息交给模型处理是目前 Agent 开发中比较推荐的做法。但生产环境要警惕模型在错误信息引导下反复调用同一个失败工具这种场景需要靠最大迭代次数和重复调用检测来兜底。5. 写核心循环把 LLM、工具、消息串起来5.1 循环的三种出口Agent 的run方法最终有且只有三种结束方式模型返回最终答案没有tool_calls返回content循环正常结束。达到最大迭代次数模型一直调用工具不收敛Agent 抛出异常终止。抛出不期望的异常例如网络错误、服务端 500向上层抛出由业务方决定重试或降级。理解这三种出口是读通主循环的关键。5.2 完整 Agent 类创建src/agent.ts实现主循环// src/agent.ts import { Message, Tool, LLMProvider, ToolCall } from ./types; export interface AgentOptions { systemPrompt?: string; maxIterations?: number; } export class Agent { private messages: Message[] []; private systemPrompt: string; private maxIterations: number; constructor( private provider: LLMProvider, private tools: Mapstring, Tool, options: AgentOptions {} ) { this.systemPrompt options.systemPrompt ?? 你是一个乐于助人的智能体。如果需要实时数据或外部信息请调用工具获取不要编造答案。; this.maxIterations options.maxIterations ?? 5; } private toolDefinitions() { return Array.from(this.tools.values()).map((t) t.definition); } async run(userInput: string): Promisestring { this.messages.push({ role: user, content: userInput }); for (let i 0; i this.maxIterations; i) { const result await this.provider.chat( this.messages, this.toolDefinitions() ); if (result.toolCalls result.toolCalls.length 0) { this.messages.push({ role: assistant, content: result.content ?? , toolCalls: result.toolCalls, }); for (const call of result.toolCalls) { const toolResult await this.runTool(call); this.messages.push({ role: tool, toolCallId: call.id, name: call.function.name, content: toolResult, }); } console.log( [Agent][第 ${i 1} 轮] 调用工具: ${result.toolCalls .map((c) c.function.name) .join(, )} ); continue; } const answer result.content ?? ; this.messages.push({ role: assistant, content: answer }); return answer; } throw new Error( Agent 在 ${this.maxIterations} 轮迭代后仍未结束请检查是否陷入工具调用循环 ); } private async runTool(call: ToolCall): Promisestring { // 该方法实现见 4.3 } }主循环的逻辑非常紧凑请求模型、判断是否要调用工具、执行工具、把结果追加到消息列表、继续下一轮。这也是标题里“100 行”的底气所在——去掉类型定义和工具实现核心循环就是几十行代码。5.3 为什么工具结果要用 tool 消息回填而不是拼成 user 消息这是一个初学者最容易踩的坑。很多人会把工具结果拼成user消息继续发例如“工具返回北京 25℃”。这种做法在效果上有一定可行性但存在两个问题协议上tool消息通过tool_call_id精确关联某次工具调用模型能清楚知道“这个结果来自哪个调用”不会因为多轮调用顺序不同而混淆。消息语义上tool角色可以区分“用户说的话”和“系统执行的客观结果”模型判断上下文更准确。所以在run方法里执行完工具后一定要追加两条消息一条assistant消息保留tool_calls表示模型刚才发出了工具调用请求。一条或多条tool消息携带tool_call_id和工具结果。这两条消息在messages数组中的顺序必须正确assistant在前tool在后。下一次调用模型时模型才能理解完整的“思考-行动-观察”链路。5.4 最大迭代次数是保命配置maxIterations不能设得太大也不能没有。它防止的是模型在一个工具上反复失败、或者不断调用工具不给出最终答案的死循环。把这个值设置成 5 到 8 在多数场景下是合理的选择。如果 Agent 的任务确实复杂例如多步骤规划可以调大但必须配合工具结果优化和日志监控否则问题会被隐藏。值效果适用场景1只允许一次工具调用简单工具、单步查询3 到 5默认推荐大多数通用任务10 以上能处理复杂多步任务规划类、子任务分解无限制危险必须禁止不推荐在任何环境使用6. 组装入口项目在真实 Node 环境跑通完整示例6.1 项目结构和依赖把上面所有文件组装成一个可运行项目agent-demo/ ├── package.json ├── tsconfig.json └── src/ ├── types.ts ├── provider.ts ├── tools.ts ├── agent.ts └── main.ts依赖尽量保持精简。运行时只需要 TypeScript 编译工具和直接运行工具mkdir agent-demo cd agent-demo npm init -y npm install typescript tsx types/node -D npx tsc --inittsx的作用是直接运行 TypeScript 文件省去先编译再执行的步骤适合学习阶段使用。Node.js 版本建议 18 及以上因为代码里使用了内置fetch和process环境变量。6.2 tsconfig 配置tsconfig.json中保持以下核心配置{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, strict: true, skipLibCheck: true, types: [node] } }moduleResolution设置为Bundler是为了让tsx能正确处理无扩展名的相对路径导入。strict模式在讲解类型设计时尤其重要它能逼着我们把Message、ToolCall这些协议字段写准确。6.3 入口代码支持环境变量和命令行参数创建src/main.ts// src/main.ts import { Agent } from ./agent; import { OpenAICompatibleProvider } from ./provider; import { createDefaultTools } from ./tools; const apiKey process.env.LLM_API_KEY ?? ; const baseUrl process.env.LLM_BASE_URL ?? https://api.example.com/v1; const model process.env.LLM_MODEL ?? your-model-name; if (!apiKey) { console.error(请先设置环境变量 LLM_API_KEY); process.exit(1); } const provider new OpenAICompatibleProvider({ apiKey, baseUrl, model }); const agent new Agent(provider, createDefaultTools(), { systemPrompt: 你是一个通用智能体。回答天气、时间等实时数据问题时先调用工具再把结果组织成易读的回答。, maxIterations: 5, }); const input process.argv[2] ?? 北京现在天气怎么样顺便告诉我现在的准确时间。; const answer await agent.run(input); console.log(answer);这里的baseUrl指向你所用服务的 OpenAI 兼容地址。不同服务商地址不同具体以官方文档为准。LLM_API_KEY、LLM_BASE_URL、LLM_MODEL三个环境变量分离是为了避免把敏感信息写进代码。6.4 运行验证配置环境变量后启动export LLM_API_KEY你的 API Key export LLM_BASE_URL你的兼容服务地址 export LLM_MODEL你的模型名称 npx tsx src/main.ts 北京现在天气怎么样顺便告诉我现在的准确时间。如果一切正常终端会先输出 Agent 的工具调用日志再输出最终回答。预期日志大致如下[Agent][第 1 轮] 调用工具: get_weather, get_current_time 北京现在的天气是晴气温约 25°C东南风 3 级空气质量优。当前时间是 2025-01-15T08:30:00.000Z北京时间 16:30。日志里最重要的信息是“第 1 轮调用了两个工具”。这意味着模型理解了问题需要实时数据并正确生成了两个工具调用。如果直接把这个问题发给不接工具的纯文本模型回答很可能包含“我无法获取实时天气”这类表述。6.5 如何验证 Agent 真的走了“工具调用”链路很多时候模型服务商会在控制台里展示调用了多少次模型接口。更直观的验证方式是观察日志中的轮次。[Agent][第 1 轮]表示第一次请求模型就返回了工具调用紧接着你会看到工具执行后继续请求模型的过程。为了看得更清楚可以在Agent的run方法末尾加一行console.log([Agent] 消息列表长度: ${this.messages.length});如果一次带工具的任务结束后消息列表长度明显大于只有一问一答的任务说明工具调用结果被正确回填到了上下文中。这也是后续排查上下文膨胀问题的基础。7. 常见问题排查Agent 跑不起来或不按预期工作时从哪查7.1 模型一直返回空内容没有 tool_calls现象Agent 正常运行但模型直接返回空字符串或者完全无视工具定义只输出普通文本。可能原因tools参数没有传给模型检查payload.tools是否为空。当前模型不支持 function calling / tool calling。系统提示没有告诉模型“可以使用工具”模型以为只能凭内部知识回答。工具的description写得太模糊模型不知道什么时候该用。排查顺序打印实际请求体确认tools数组不为空。查看模型服务商文档确认所选模型支持工具调用。临时把弱描述改成更强引导的描述例如在系统提示中增加“回答实时数据问题时必须调用工具”。处理建议升级到支持 tool calling 的模型或者在系统提示里明确工具的使用条件。空内容问题通常在模型本身不支持工具时会特别明显。7.2 工具参数解析失败模型反复拿到“不是合法 JSON”现象日志中出现工具参数不是合法 JSON下一轮模型又生成了同样的错误参数进入循环。可能原因模型在极低temperature下仍偶尔生成不完整 JSON。工具参数结构过于复杂嵌套层级太多。模型不理解某个参数应该填什么干脆输出空字符串或undefined。排查顺序打印call.function.arguments原始内容看看模型到底输出了什么。检查工具定义中的参数说明是否清晰是否需要增加enum或示例值。在runTool中增加容错解析失败时向模型明确提示“参数必须是合法 JSON严格按照约定的 properties 填写”。处理建议简化参数结构给每个参数写清楚description必要时在parameters中补充examples。不要只依赖模型能力工具定义本身就是在“教”模型怎么调用。7.3 无限循环模型一直调用同一个工具现象日志输出了很多轮工具调用但一直没有最终答案直到maxIterations耗尽抛错。可能原因工具结果不够明确模型无法根据结果判断下一步。模型幻觉一直重复调同一个工具。工具结果格式混乱模型读不懂。排查顺序查看每轮工具调用名称和参数判断是否重复。查看工具返回的字符串是否结构化清晰例如是否返回 JSON。临时把maxIterations调到 10观察第 4、5 轮到底发生了什么。处理建议让工具返回内容更结构化例如明确包含status字段在代码里检测连续三次调用同一工具同一参数直接抛出异常终止循环。生产环境还要给maxIterations设置一个较小的默认值比如 5。7.4 消息越来越长超出模型上下文限制现象多轮会话后请求报错错误信息类似context length exceeded。可能原因每次run都会往messages里追加消息工具调用轮次越多消息越长。尤其是工具结果如果返回大段 JSON上下文会快速膨胀。排查顺序打印messages长度和估算 token 数。检查是否有工具返回了不必要的长文本。查看模型服务商日志里的 token 用量。处理建议工具结果只保留核心字段不要返回冗余日志。对历史消息做窗口裁剪只保留最近若干条。引入摘要记忆把早期对话压缩成一段总结再用system消息注入。7.5 多个工具调用串行执行速度太慢现象一次任务调用了 3 个工具耗时是单个工具的 3 倍。原因当前runTool循环是串行执行的工具之间没有依赖关系时可并行。处理建议const toolResults await Promise.all( result.toolCalls.map(async (call) ({ call, content: await this.runTool(call), })) ); for (const { call, content } of toolResults) { this.messages.push({ role: tool, toolCallId: call.id, name: call.function.name, content, }); }并行执行能显著降低多工具任务的响应时间但前提是工具之间没有状态依赖且工具实现本身是幂等的。7.6 排查问题速查表现象优先检查处理建议请求 400tools 格式、messages 字段名打印实际请求体逐字段比对文档长时间无结果是否死循环降低 maxIterations打开日志工具结果不准工具实现本身先用 curl 独立验证工具模型不调工具prompt 或模型能力显式要求并更换支持工具调用的模型上下文超限历史消息长度窗口裁剪、总结记忆、压缩工具结果工具调用很慢是否串行执行无依赖工具改为 Promise.all8. 从 100 行到生产级扩展方向与最佳实践8.1 记忆管理不能只靠 messages 无限追加最小示例把消息全部放在messages数组里适合学习但不适合生产。多轮会话之后这个数组会越来越长既消耗 token也降低模型对关键信息的注意力。生产环境可以从三个层面解决窗口裁剪只保留最近 N 轮消息。摘要记忆把早期对话用模型总结成一段话作为system消息注入。外部记忆把重要信息写入向量数据库或 KV 存储按需检索后拼入上下文。记忆管理的原则是给模型的永远是“完成当前任务所需的最少信息”而不是把所有历史都塞进去。8.2 可观测性每一步都要能追踪Agent 循环是非线性的模型可能一轮只调一个工具也可能一轮调多个还可能连续多轮调整参数。没有日志就很难定位是“模型决策错误”还是“工具执行错误”。推荐在每个关键节点记录以下信息每轮请求消耗的 token 数。模型返回的工具名称、参数原文。工具执行耗时和返回结果摘要。最终答案来源是第几轮。如果预算允许还可以接入 OpenTelemetry 之类的链路追踪把一次 Agent 任务拆成一组 span每个 span 记录一次模型请求或一次工具调用。这样可以回答“用户等 5 秒时间花在哪个模型请求上了”这类问题。8.3 安全和权限工具越强约束越要严格工具是 Agent 的最大边界扩展也是最容易引入安全风险的地方。一个能执行任意 Shell 命令的 Agent如果提示词被注入恶意指令后果会非常严重。生产环境至少要遵守以下原则工具最小权限Agent 只挂完成任务真正需要的工具不提供万能工具。敏感操作人工确认删除、修改、转账、发布类操作工具执行前先由用户确认。工具结果不可轻信外部 API 返回的内容可能包含恶意指令不要直接作为指令执行。日志脱敏工具参数和返回结果如果包含密钥、手机号、身份证要先脱敏再落日志。调用合规使用模型服务商的 API 时要遵守其服务条款和所在地区的法律法规不要在代码中硬编码密钥也不要将密钥提交到仓库。8.4 从单 Agent 到多智能体协作本文实现的是单个 Agent 的闭环。真实业务中经常出现更复杂的场景例如一个“规划器”Agent 负责拆解任务多个“执行器”Agent 分别负责查询、编写、审核。多个 Agent 共享上下文通过消息队列交换结果。一个“主管”Agent 根据子任务结果决定是否继续或终止。扩展的核心要点是保持本文的抽象层级每个 Agent 仍然是一个独立的循环只是消息来源不再只是用户也可能是其他 Agent 的输出。基于现有代码扩展时只需要把run(userInput)的输入来源改一下或者新增一个调度器来编排多个 Agent 实例。8.5 生产环境发布前检查清单把最小示例改成生产级代码前建议逐项核对以下清单检查项要求最大迭代次数必须有上限默认 5 到 8请求超时模型请求和工具请求都要设置超时重试机制模型请求只对可重试错误重试工具失败交给模型处理API Key 管理环境变量或密钥管理服务禁止写进代码日志每个工具调用都有结构化日志方便回放监控告警设置工具失败率、平均轮次、上下文长度指标工具幂等重复执行同一工具参数不会产生副作用数据脱敏日志和上下文不出现敏感信息明文模型版本固定版本号避免模型行为漂移回归测试用固定问题集合验证工具调用行为没有退化如果你刚接触 Agent建议先不要急着上框架。把这套代码跑通试着加一个自己的工具例如查询文件、查数据库、调用公司内部 API然后再回头看 LangGraph 这类框架的设计你会发现它们的编排模型并没有超出“循环 工具 消息”这个基本盘。理解这个基本盘才是驾驭各种 Agent 框架的关键。