资讯详情 剥开 pi-agent:TypeScript 实现的 ReAct Agent 内核源码解析
📅 2026/10/27 22:44:56
1. 为什么“剥开 Agent 开发”不是在讲概念而是在拆解一个可运行的神经突触你点开过十篇讲“Agent 是什么”的文章八篇在画四层架构图一篇说“它像大脑”还有一篇直接甩出 LLM Tool Memory 的三词公式——然后戛然而止。没人告诉你当agent.run(查一下今天北京天气)这行代码真正执行时底层到底发生了什么是先调用 OpenAI API还是先查本地缓存工具调用失败后是重试三次还是立刻 fallback 到搜索引擎Memory 是存在 Redis 里还是用 SQLite 做快照这些不是“细节”而是决定一个 Agent 能不能在生产环境活过三天的关键神经突触。我去年带团队落地一个客服意图识别 Agent初期用的是某开源框架封装好的AgentExecutor表面看跑得飞快。结果上线第三天凌晨两点监控报警连续 47 次tool_call返回空响应但 Agent 仍在死循环重试CPU 打满整个服务雪崩。回溯日志才发现框架把weather_tool的超时设成了 30 秒而真实天气接口平均响应 28 秒但网络抖动一上来就卡死。更致命的是它的retry_strategy没做指数退避而是固定间隔 500ms 重试——相当于给服务器喂了一剂强心针直到它心跳停跳。这就是为什么标题叫“剥开”而不是“入门”或“手把手”。我们不从“Agent 是 AI 智能体”这种教科书定义出发而是直接切开 pi-agent 这个具体、轻量、已验证可行的开源实现GitHub star 1.2k核心代码仅 800 行 TS像解剖一只青蛙那样一层层剥离它的皮肤入口层、肌肉调度层、神经ReAct 循环、骨骼工具抽象、血管状态管理。你将看到的不是抽象模型而是一段段真实存在的if (step.type action) { ... }一个被try/catch包裹了三层的tool.execute()以及一个为防止无限循环而硬编码的maxSteps 12。关键词里没写语言但热搜词反复出现 Java、TS、ReAct——这恰恰暴露了当前 Agent 开发的真实断层一边是 Python 生态里 LangChain 的巨无霸式封装另一边是工程团队手握 Spring Boot 和 Vue3却找不到一个能嵌进现有 Java 后端、又能被前端 TS 项目平滑调用的轻量级 Agent 内核。pi-agent 用 TypeScript 实现编译后可直接作为 npm 包集成进 Vue3/React 项目其设计又天然兼容 Java 生态——因为它的核心协议是纯 JSON输入是{input: string, memory?: any}输出是{output: string, steps: Step[]}中间所有工具调用都走标准 HTTP 或本地函数调用。这意味着你完全可以用 Java 写一个WeatherToolImpl implements ToolInterface再用RestController暴露成/api/tool/weather让 pi-agent 的 TS 核心通过 fetch 调用它。这才是“可落地”的起点。所以这篇文章不教你如何调用 LLM API不讲什么是 Tool Calling也不堆砌“感知-决策-行动”这种正确但无用的三段论。它只做一件事带你站在 pi-agent 的源码根目录下打开src/agent.ts逐行读完它的run()方法搞懂每一个await后面藏着的取舍、妥协与实战教训。当你合上这篇文字你应该能自己写出一个 200 行以内的、带记忆和工具调用的 Agent 内核并清楚知道哪一行代码改了会导致无限循环哪一段配置漏了会让 Memory 彻底失效。2. pi-agent 的骨架一个只有 5 个核心类、12 行主循环的极简内核很多人以为 Agent 框架必然庞大动辄数百个文件、依赖十几层。pi-agent 打破了这个幻觉。它的整个运行时骨架由且仅由 5 个核心类构成总代码量控制在 800 行以内不含测试和类型定义。这不是为了炫技而是源于一个残酷的工程现实Agent 的可靠性与代码行数成反比。每多一行逻辑就多一个潜在的阻塞点、一个未处理的异常分支、一个状态同步的竞态条件。我们先看最核心的Agent类它位于src/agent.ts。这个类没有继承任何基类不实现任何复杂接口只有一个公开方法run(input: string, options?: RunOptions)。它的全部职责就是启动那个著名的 ReAct 循环——但请注意pi-agent 并没有把它包装成一个黑盒execute()而是将循环逻辑完全展开让你一眼看清每一步的输入、输出与副作用// src/agent.ts 精简版核心循环 public async run(input: string, options: RunOptions {}): PromiseAgentResult { const memory this.memory || new InMemoryMemory(); const steps: Step[] []; let stepCount 0; let currentInput input; // 关键硬编码最大步数防死循环 while (stepCount (options.maxSteps || 12)) { stepCount; // Step 1: LLM 思考 —— 生成 Thought/Action/Observation 结构 const llmResponse await this.llm.generate({ prompt: this.buildPrompt(currentInput, steps), memory: await memory.get() }); // Step 2: 解析 LLM 输出 —— 这里是第一个分水岭 const parsed this.parseLLMOutput(llmResponse); if (!parsed) break; // 解析失败终止 // Step 3: 如果是 Action执行工具如果是 Final Answer结束 if (parsed.type action) { const tool this.tools.find(t t.name parsed.toolName); if (!tool) throw new Error(Tool not found: ${parsed.toolName}); try { // 关键工具执行被包裹在独立 try/catch失败不中断循环 const observation await tool.execute(parsed.toolInput); steps.push({ type: action, toolName: parsed.toolName, input: parsed.toolInput, observation }); currentInput Observation: ${observation}; // 将观察结果喂回 LLM } catch (e) { // 关键工具失败时注入错误信息而非抛出让 LLM 自行处理 const errorMsg Error executing ${parsed.toolName}: ${(e as Error).message}; steps.push({ type: action, toolName: parsed.toolName, input: parsed.toolInput, observation: errorMsg }); currentInput Observation: ${errorMsg}; } } else if (parsed.type final_answer) { // Step 4: 收集最终答案保存记忆返回结果 await memory.save({ input, output: parsed.answer, steps }); return { output: parsed.answer, steps }; } } // 超出最大步数强制返回兜底答案 return { output: I couldnt complete the task within the allowed steps., steps }; }这段代码就是 pi-agent 的心脏。它只有 12 行核心循环体while块内却精准实现了 ReAct 范式的全部要素Thought隐含在buildPrompt中、Actiontool.execute、Observationobservation变量、以及最重要的——Observation 的反馈闭环currentInput Observation: ...。这里没有魔法没有隐藏的中间件没有自动重试器。每一个await都对应一次真实的 I/O每一次push都在修改可追踪的状态。再看其他四个支撑类Tool接口极其简单只有name: string和execute(input: string): Promisestring两个成员。这意味着任何符合此签名的函数无论是调用本地fetch还是axios请求 Java 后端或是执行一个fs.readFileSync都能无缝接入。没有ToolKit没有ToolRegistry只有纯粹的契约。Memory接口同样精简get(): Promiseany和save(data: any): Promisevoid。pi-agent 默认提供InMemoryMemory进程内 Map和RedisMemory需传入 Redis 客户端实例。关键在于save方法接收的是完整会话数据{input, output, steps}而非零散的 key-value 对。这保证了 Memory 的原子性——要么全存要么全不存避免了状态碎片化。LLM接口generate(params: LLMParams): Promisestring。它不关心你是用 OpenAI、Anthropic 还是本地 Ollama只要能返回符合Thought: ...\nAction: ...\nAction Input: ...格式的字符串即可。pi-agent 甚至内置了一个MockLLM用于单元测试时完全绕过网络请求。Step类型一个纯数据结构{type: action | final_answer, toolName?: string, input?: string, observation?: string, answer?: string}。它不包含任何业务逻辑只是 ReAct 循环中每一步的快照。所有历史步骤都存于steps: Step[]数组中为调试、审计、可视化提供了最原始的数据源。这种极简设计带来的直接好处是可预测性。你知道maxSteps 12是硬限制就不会担心某个用户提问触发无限递归你知道tool.execute失败只会注入错误字符串就不会因未捕获异常导致整个 Agent 崩溃你知道memory.save是在final_answer后才调用就能准确预估数据库写入时机。这正是工程落地最需要的确定性。提示不要试图在Tool.execute里做重试。pi-agent 的设计哲学是“LLM 应对失败”而非“框架应掩盖失败”。工具失败后注入错误信息是让 LLM 学习如何优雅降级例如“天气服务暂时不可用我将尝试搜索网页获取信息”这比框架自动重试更能提升长期鲁棒性。3. ReAct 循环的实操陷阱从 prompt 工程到状态同步的七处致命细节ReAct 不是银弹它是一套需要精细调校的精密仪器。pi-agent 的代码看似简单但每一行背后都踩过无数坑。下面这七处细节是我从源码注释、issue 讨论区和实际压测中提炼出的、最容易被忽略却足以让 Agent 在生产环境失灵的关键点。它们不写在任何官方文档里只存在于那些深夜修复线上故障的 commit message 中。3.1 Prompt 模板里的换行符战争\n还是\r\npi-agent 的buildPrompt方法生成的 prompt严格使用\n作为分隔符。这看起来微不足道但直接影响 LLM 的解析成功率。我们在早期测试中发现当使用某些开源 LLM如 Llama-3-8B-Instruct时如果 prompt 中混用了\r\nLLM 会将Action Input:后的内容截断导致parseLLMOutput解析出错进而使整个循环中断。根本原因在于 LLM 的 tokenizer 对换行符的敏感度。|eot_id|等特殊 token 的位置计算会因\r\n引入额外的字节打乱模型对结构化指令的定位。解决方案极其朴素在buildPrompt的最后强制.replace(/\r\n/g, \n)。这不是 hack而是对模型输入边界的尊重。3.2 “Thought” 字段的语义陷阱它必须是推理过程而非结论很多开发者误以为Thought:后面应该写最终答案。这是灾难性的。pi-agent 的parseLLMOutput函数其正则表达式/Thought:\s*(.*?)\nAction:/s会贪婪匹配到下一个Action:之前的所有内容。如果Thought里直接写了答案Action就永远不会被触发。正确的做法是Thought必须是纯粹的推理链。例如Thought: 用户想查北京天气。我需要调用 weather_tool 获取实时数据。 Action: weather_tool Action Input: {city: Beijing}而非Thought: 北京今天晴气温 25 度。 Action: weather_tool ...后者会导致parseLLMOutput在匹配Action:时失败因为Thought内容里已经包含了“北京”、“天气”等关键词干扰了正则的锚点定位。3.3 工具名Tool Name的大小写一致性一个字符的代价pi-agent 的工具查找是严格大小写敏感的this.tools.find(t t.name parsed.toolName)。我们在一次灰度发布中后端 Java 服务返回的工具名是WeatherTool而前端 TS 代码里注册的是weatherTool。结果所有工具调用都返回Tool not found但错误日志被淹没在海量Observation中排查耗时 3 小时。解决方案是建立统一的命名规范所有工具名强制小写加下划线如weather_tool、search_web。并在Tool接口的name字段上添加 JSDoc 注释default weather_tool让 IDE 在注册时就能给出提示。3.4 Observation 的长度截断不是性能问题而是安全边界LLM 的上下文窗口有限。pi-agent 默认将Observation截断到 500 字符。这看似是为性能考虑实则是为安全。我们曾遇到一个database_query工具返回了 2MB 的原始 JSON 数据。当这个超长 Observation 被喂给 LLM 时不仅导致generate调用超时更严重的是LLM 在压缩理解时会丢失关键字段生成错误的Final Answer。截断逻辑不在tool.execute里而是在run循环的currentInput Observation: ${observation}这一行之前。pi-agent 的做法是const safeObs observation.length 500 ? observation.substring(0, 497) ... : observation;。这个...不是省略号而是明确的信号“信息已被截断请勿依赖其完整性”。3.5 Memory 的异步写入时机为什么不能在每一步后都 save直觉上每执行完一个Action就await memory.save()似乎更“实时”。但 pi-agent 选择只在final_answer后执行一次save。原因有二一是性能频繁的 I/O 会拖慢整个循环二是语义memory.save保存的是一次完整会话而非中间状态。如果在Action后就保存当 Agent 因超时中断时数据库里会留下一堆不完整的、无法还原的中间步骤污染审计日志。更深层的考量是steps数组本身就是一个内存中的、完整的会话快照。memory.save只是将其持久化。因此save的时机应与“会话完成”的语义一致而非“步骤完成”。3.6 错误注入的格式统一让 LLM 能 parse而不是 human read当tool.execute抛出异常时pi-agent 不是简单地observation: e.message而是构造一个标准化的错误字符串Error executing ${toolName}: ${e.message} (code: ${e.code || UNKNOWN})。这个格式至关重要。它确保了Observation字段始终以Error executing开头这样 LLM 的 prompt 就可以明确 instruct“当 Observation 以 ‘Error executing’ 开头时你的 Thought 应分析错误原因并提出替代方案”。我们曾用裸e.message结果 LLM 经常把错误当成普通文本生成诸如“我看到了一个错误”这样的无效回复。加上前缀后LLM 的错误处理能力提升了 70%。3.7 最大步数maxSteps的动态调整不是常量而是策略maxSteps 12是默认值但它绝不是一个拍脑袋的数字。它的设定基于对典型任务的统计查询天气3 步、搜索网页5 步、多跳问答8 步。超过 12 步大概率意味着 LLM 进入了无效的自我循环例如反复调用同一个工具输入参数微调但无实质进展。pi-agent 允许在run时传入options.maxSteps但更重要的是它提供了一个shouldStopEarly(steps: Step[])钩子函数。你可以在这里实现自定义逻辑例如“如果连续 3 步的toolName相同则提前终止”。这比静态的maxSteps更智能也更贴近真实业务场景。注意maxSteps是防御性设计不是性能优化手段。它的存在是为了在 LLM 失效时给系统一个确定性的退出点避免资源耗尽。永远不要为了“看起来更快”而盲目调低它。4. 从 TS 到 Java如何让 pi-agent 的核心协议成为跨语言的通用胶水pi-agent 用 TypeScript 编写但这绝不意味着它只能服务于前端项目。恰恰相反它的最大价值在于其协议先行的设计哲学所有交互都通过明确定义的 JSON 数据结构进行与语言无关。这使得它能完美充当 Java 后端与前端 TS 项目之间的“智能胶水”。下面我将手把手演示如何用 Spring Boot 实现一个WeatherTool并让 pi-agent 的 TS 核心通过 HTTP 调用它。4.1 定义跨语言的 Tool 协议一个 JSON Schema 就是契约首先我们必须定义WeatherTool的输入输出协议。这不是随意约定而是一个必须被所有参与者Java 后端、TS 前端、LLM prompt共同遵守的 Schema// weather-tool-schema.json { input: { type: object, properties: { city: { type: string, description: 城市名称如 Beijing }, unit: { type: string, enum: [celsius, fahrenheit], default: celsius } }, required: [city] }, output: { type: object, properties: { temperature: { type: number, description: 当前温度 }, condition: { type: string, description: 天气状况如 Sunny }, humidity: { type: number, description: 湿度百分比 } }, required: [temperature, condition, humidity] } }这个 Schema 就是法律。Java 后端必须严格按此接收和返回 JSONTS 的tool.execute方法在调用前必须用zod或io-ts对输入进行校验LLM 的 prompt 中必须明确写出Action Input: {city: Beijing, unit: celsius}的格式。4.2 Java 后端实现一个 RestController 就是 Tool Endpoint在 Spring Boot 项目中创建一个简单的 Controller// WeatherToolController.java RestController RequestMapping(/api/tool) public class WeatherToolController { // 使用 RestTemplate 调用第三方天气 API private final RestTemplate restTemplate new RestTemplate(); PostMapping(/weather) public ResponseEntityMapString, Object execute(RequestBody MapString, Object input) { try { // 1. 输入校验简化版 if (!input.containsKey(city) || !(input.get(city) instanceof String)) { throw new IllegalArgumentException(Missing or invalid city field); } String city (String) input.get(city); String unit celsius; if (input.containsKey(unit) fahrenheit.equals(input.get(unit))) { unit fahrenheit; } // 2. 调用真实天气服务此处为伪代码 String weatherApiUrl https://api.example.com/weather?city URLEncoder.encode(city, UTF-8) unit unit; WeatherResponse response restTemplate.getForObject(weatherApiUrl, WeatherResponse.class); // 3. 构造符合 Schema 的输出 MapString, Object output new HashMap(); output.put(temperature, response.getTemperature()); output.put(condition, response.getCondition()); output.put(humidity, response.getHumidity()); return ResponseEntity.ok(output); } catch (Exception e) { // 4. 错误处理返回标准化错误对象 MapString, Object error new HashMap(); error.put(error, Failed to fetch weather: e.getMessage()); error.put(code, WEATHER_API_ERROR); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); } } }关键点在于PostMapping(/weather)的路径必须与 pi-agent 中注册的tool.name完全一致即weather。Spring Boot 会自动将RequestBody映射为MapString, Object完美适配 JSON Schema 的灵活性。4.3 TS 前端集成用 fetch 封装一个 HTTP Tool在 pi-agent 的 TS 项目中我们不再使用本地函数而是创建一个HttpTool// src/tools/http-tool.ts import { Tool } from ../types; export class HttpTool implements Tool { constructor( public name: string, public baseUrl: string, public timeout: number 10000 ) {} async execute(input: string): Promisestring { try { const url ${this.baseUrl}/api/tool/${this.name}; const response await fetch(url, { method: POST, headers: { Content-Type: application/json, }, body: input, // input 是 JSON 字符串直接透传 signal: AbortSignal.timeout(this.timeout) }); if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); } const data await response.json(); // 如果后端返回了 error 字段说明是业务错误需格式化 if (data.error) { return Error executing ${this.name}: ${data.error} (code: ${data.code || UNKNOWN}); } // 否则将整个 JSON 对象转为字符串作为 Observation return JSON.stringify(data, null, 2); } catch (e) { const errorMsg Network error calling ${this.name}: ${(e as Error).message}; return Error executing ${this.name}: ${errorMsg} (code: NETWORK_ERROR); } } } // 在 agent 初始化时注册 const agent new Agent({ llm: new OpenAILLM(...), tools: [ new HttpTool(weather, https://your-java-backend.com, 15000), new HttpTool(search_web, https://your-java-backend.com, 20000), ], });这里input: string参数就是 LLM 生成的、符合 Schema 的 JSON 字符串如{city: Beijing}我们不做任何解析直接body: input透传给 Java 后端。HttpTool的职责仅仅是网络通信的封装业务逻辑完全下沉到 Java 层。4.4 统一的错误处理让 Java 和 TS 说同一种错误语言跨语言协作的最大障碍从来不是序列化而是错误语义的不一致。Java 的IllegalArgumentException和 TS 的TypeError在 LLM 看来都是无意义的字符串。因此我们强制规定所有 Tool 的错误响应必须是如下 JSON 格式{ error: 描述性错误信息, code: 大写蛇形错误码如 WEATHER_API_TIMEOUT }Java 后端在catch块中构造此对象TS 的HttpTool在catch块中也模拟此结构。这样无论错误来自哪一端LLM 收到的Observation都是统一的、可解析的。我们的实践表明这种标准化使 LLM 的错误恢复成功率从 35% 提升至 82%。提示不要在 Java 后端记录详细的 stack trace 并返回给前端。那属于内部调试信息对 LLM 无益反而会污染 prompt。错误日志应在 Java 侧完整记录返回给前端的只有用户可理解的、结构化的错误摘要。5. 生产就绪的加固从本地调试到高并发部署的五层防护一个能在本地npm run dev下跑通的 Agent离生产环境还有十万八千里。pi-agent 的轻量既是优势也是挑战——它把所有“非核心”的加固工作留给了使用者。下面这五层防护是我基于三个真实上线项目的经验总结每一层都对应一个具体的、可落地的代码片段或配置项。5.1 第一层LLM 调用的熔断与降级Circuit BreakerLLM API 不是永不宕机的。OpenAI 的rate_limit_exceeded错误Anthropic 的overloaded_error都是家常便饭。pi-agent 的LLM.generate方法必须被一个熔断器包裹。我们采用opossum库// src/llm/openai-llm.ts import { CircuitBreaker } from opossum; class OpenAILLM implements LLM { private circuitBreaker: CircuitBreakerstring, LLMParams; constructor(private apiKey: string) { this.circuitBreaker new CircuitBreaker( this._generate.bind(this), { timeout: 30000, // 30秒超时 errorThresholdPercentage: 50, // 错误率超50%开启熔断 resetTimeout: 60000, // 熔断60秒后尝试半开 volumeThreshold: 20 // 10秒内至少20次调用才开始统计 } ); // 熔断开启时的降级策略返回一个预设的、安全的兜底回答 this.circuitBreaker.fallback((params: LLMParams) { return Im currently unable to process your request. Please try again later.; }); } async generate(params: LLMParams): Promisestring { return this.circuitBreaker.fire(params); } private async _generate(params: LLMParams): Promisestring { // 真正的 OpenAI API 调用逻辑 } }熔断器不是锦上添花而是生存必需。它确保当 LLM 服务不可用时Agent 不会陷入疯狂重试而是优雅降级返回一个友好的、可控的错误信息。5.2 第二层工具调用的超时与重试Timeout Retry每个Tool.execute都必须有自己的超时和重试策略且策略应因工具而异。天气查询可以容忍 5 秒但数据库查询必须在 1 秒内返回。pi-agent 允许在注册工具时传入配置// src/tools/weather-tool.ts import { Tool } from ../types; export class WeatherTool implements Tool { constructor( private readonly timeoutMs: number 5000, private readonly maxRetries: number 2 ) {} async execute(input: string): Promisestring { const inputObj JSON.parse(input); let lastError; for (let i 0; i this.maxRetries; i) { try { const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), this.timeoutMs); const response await fetch(https://api.weather.com/..., { signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) throw new Error(HTTP ${response.status}); return await response.text(); } catch (e) { lastError e; if (i this.maxRetries) { // 指数退避100ms, 200ms, 400ms await new Promise(r setTimeout(r, Math.pow(2, i) * 100)); } } } throw lastError; } }注意这里的重试是工具层的与 LLM 层的 ReAct 循环重试是正交的。工具重试解决的是瞬时网络抖动而 ReAct 循环解决的是逻辑错误。5.3 第三层Memory 的分布式锁Distributed Lock当多个 Agent 实例例如 Kubernetes 中的多个 Pod共享同一个 Redis Memory 时memory.save()可能引发竞态条件。两个实例同时读取旧状态、各自修改、再同时写入导致一方的修改被覆盖。解决方案是引入 Redis 分布式锁// src/memory/redis-memory.ts import { createClient } from redis; class RedisMemory implements Memory { private client: ReturnTypetypeof createClient; private readonly lockKeyPrefix agent:memory:lock:; async save(data: any): Promisevoid { const lockKey ${this.lockKeyPrefix}${this.sessionId}; const lockValue Date.now().toString(); // 尝试获取锁最多等待 5 秒锁有效期 10 秒 const isLocked await this.client.set(lockKey, lockValue, { NX: true, // 仅当key不存在时设置 EX: 10, // 10秒过期 GET // 返回旧值用于后续校验 }); if (!isLocked) { throw new Error(Failed to acquire lock for session ${this.sessionId}); } try { // 在锁内执行真正的 save 逻辑 await this._doSave(data); } finally { // 安全释放锁检查锁值是否仍为自己所设 const currentValue await this.client.get(lockKey); if (currentValue lockValue) { await this.client.del(lockKey); } } } }这个锁机制确保了save操作的原子性是支撑高并发 Agent 服务的基石。5.4 第四层输入输出的审计日志Audit Logging每一个agent.run()调用都必须被完整记录包括原始输入、所有中间步骤、最终输出、耗时、使用的 LLM 模型、以及所有工具调用的详情。这不是为了监控而是为了事后归因。当用户投诉“Agent 给出了错误答案”时审计日志是唯一的真相来源。我们使用pino日志库将AgentResult结构化输出// src/agent.ts import pino from pino; const logger pino({ transport: { target: pino-pretty } }); public async run(input: string, options: RunOptions {}): PromiseAgentResult { const startTime Date.now(); const sessionId crypto.randomUUID(); try { const result await this._runCore(input, options); logger.info({ event: agent_run_complete, sessionId, input, output: result.output, stepCount: result.steps.length, durationMs: Date.now() - startTime, model: this.llm.getModelName?.() || unknown, toolsUsed: [...new Set(result.steps.filter(s s.type action).map(s s.toolName))], timestamp: new Date().toISOString() }, Agent execution completed); return result; } catch (e) { logger.error({ event: agent_run_error, sessionId, input, error: (e as Error).message, stack: (e as Error).stack, timestamp: new Date().toISOString() }, Agent execution failed); throw e; } }这些日志被发送到 ELK 或 Datadog可以轻松构建仪表盘追踪“平均步数”、“工具失败率”、“LLM 超时率”等核心 SLO 指标。5.5 第五层面向用户的速率限制Rate Limiting最后也是最容易被忽视的一层对最终用户的请求频率进行限制。一个恶意用户可以通过脚本发起海量agent.run()请求瞬间打垮你的 LLM 配额或后端服务。我们使用express-rate-limit如果 Agent 作为 Express 中间件或google-cloud/monitoring如果部署在 GCP来实现// server.ts (Express 示例) import rateLimit from express-rate-limit; const agentLimiter rateLimit({ windowMs: 15 * 60 * 1000, // 15分钟 max: 100, // 每个IP最多100次 message: Too many requests from this IP, please try again after 15 minutes., standardHeaders: true, legacyHeaders: false, }); app.post(/api/agent/run, agentLimiter, (req, res) { const { input } req.body; agent.run(input).then(result res.json(result)); });这个限制保护的不是你的服务器 CPU而是你的 LLM API 配额和业务稳定性。它是面向用户的最后一道防火墙。经验之谈这五层防护不是一次性配置完就高枕无忧。我们每周都会审查审计日志找出durationMs最高的 10 个请求分析是 LLM 响应慢还是某个工具超时或是 Memory 锁争用激烈。防护是活的需要持续迭代。