1. 这不是又一个“AI Agent 教程”,而是一次对真实工程边界的叩问
你点开这个标题,大概率不是想学怎么用 LangChain 写个能查天气的 demo。你真正想搞清楚的是:当一个项目在 GitHub 上攒到 10 万颗星,它到底做对了什么?为什么它没被淹没在成千上万个“5 分钟教会你写 AI Agent”的教程里?为什么它的代码仓库里没有一堆 copy-paste 的胶水代码,反而让人愿意花时间读它的Cargo.toml和tsconfig.json?这背后不是玄学,是软件工程在 LLM 时代的一次硬核重演——不是把旧范式套上新名词,而是用十年沉淀下来的工程肌肉,在大模型的混沌里重新划出清晰的边界。
我过去三年深度参与过三个从零启动的 AI Agent 产品落地,其中两个在交付前因架构失控被砍掉,第三个勉强上线但技术债堆积如山。直到我完整扒完那个 10 万星项目的源码、CI 日志、PR Review 记录和 issue 闭环路径,才真正明白:所谓“真正的软件工程”,不是 UML 图画得多漂亮,而是当你面对一个会随机 hallucinate 的 LLM、一个每小时变更三次 API 的向量数据库、一个永远在 beta 的推理服务时,依然能保证系统在凌晨三点不崩、新成员三天内能独立提交有效 PR、业务方提的需求不会让后端工程师集体辞职。它体现在src/agent/core/planning.rs里那个被反复重构了 7 次的PlanExecutortrait,体现在packages/core/src/telemetry.ts中对 LLM token 耗费的粒度追踪,更体现在.github/workflows/ci.yml里那行被注释掉又恢复的# - name: Validate LLM schema against OpenAPI spec。这些不是“最佳实践”,是踩着坑、熬着夜、被线上报警逼出来的生存策略。如果你正打算用 Python 快速搭个 Agent 原型,或者准备用 TypeScript 写一个“智能客服中台”,这篇文章里拆解的每一个决策点,都可能帮你省下三个月返工时间。
2. 项目整体设计与思路拆解:为什么 Rust + TypeScript 不是炫技,而是防御性选择
2.1 核心矛盾:LLM 的不可靠性 vs 系统的确定性需求
所有 AI Agent 项目的起点,都是一个看似简单的等式:Agent = LLM + Tool Calling + Memory + Planning。但现实很快会撕碎这个等式。LLM 的输出永远带着概率性噪声——它可能把“查询用户订单”解析成“删除用户账户”,可能把 JSON Schema 的字段名拼错,可能在 token 限制边缘突然截断响应。而生产环境要求的是确定性:订单查询失败必须返回明确错误码,工具调用失败必须可重试且不丢失上下文,内存状态变更必须原子化。传统 Web 开发中靠 try-catch 和重试机制兜底的方案,在 LLM 驱动的长链路里会指数级放大不确定性。那个 10 万星项目的第一层设计哲学,就是用语言本身的约束力,对抗 LLM 的混沌。
他们没选 Python,不是因为 Python 不好,而是因为 Python 的动态类型在 Agent 场景下成了放大器。当 LLM 返回一个结构模糊的 JSON,json.loads()解析后得到一个dict,后续所有操作都依赖开发者手动if "tool_name" in response或try: response["args"]["user_id"]。这种防御性编程在单次调用中尚可忍受,但在一个包含 12 个工具、3 层规划嵌套、支持 5 种记忆存储后端的系统里,会迅速演变成维护噩梦。而 Rust 的Result<T, E>和Option<T>强制你处理每一个分支;TypeScript 的严格类型检查让你在tsc阶段就捕获 80% 的 schema 错误;两者结合,构建了一道“编译期防火墙”——LLM 输出的任何非法结构,都会在进入核心逻辑前被拦截并格式化为标准错误,而不是让错误在运行时随机爆炸。
提示:这不是“Rust 更快”的性能问题,而是“Rust 更稳”的工程问题。该项目压测数据显示,Rust 实现的 Planner 模块在 99.9% 的请求下耗时稳定在 120ms±5ms,而同等逻辑的 Python 版本在 LLM 响应波动时,P99 耗时会飙升至 2.3s。稳定性差异直接决定了能否在金融风控场景中使用。
2.2 架构分层:为什么把 LLM 当作“不可信外部服务”,而非“智能内核”
绝大多数新手教程把 LLM 当作 Agent 的“大脑”,所有逻辑围绕 prompt engineering 展开。而该顶级项目反其道而行之:LLM 是一个需要被严格封装、监控、降级的第三方 HTTP 服务。整个系统被划分为四个物理隔离层:
- Orchestration Layer(编排层):Rust 编写,负责接收用户输入、调用 Planner、管理执行上下文、处理超时与熔断。它从不直接接触 LLM,只通过定义好的
LLMRequest和LLMResponse结构体与之通信。 - Planner Layer(规划层):Rust 编写,核心是
Plan和Step两个 enum。每个Step包含ToolCall、WaitForInput、Fallback等变体,强制所有规划路径在编译期可枚举。 - Tooling Layer(工具层):TypeScript 编写,每个工具实现
ToolInterface,必须提供schema(JSON Schema)、execute(异步函数)、validateInput(输入校验)。工具注册时自动注入 OpenAPI 文档生成逻辑。 - LLM Adapter Layer(适配层):Rust 编写,仅包含
call_llm()函数,内部封装了重试、token 统计、响应解析(用serde_json强制校验 schema)、错误分类(LLMError::RateLimited/LLMError::InvalidSchema/LLMError::Timeout)。
这种设计带来的直接好处是:当 LLM 服务商 API 变更时,只需修改 Adapter 层的 3 个函数;当需要替换 LLM 时,只需实现新的 Adapter;当某个工具出错时,Planner 可以精确知道是哪个Step失败,并触发预设的Fallback逻辑。我曾见过一个团队把 LLM 调用直接写在 React 组件里,结果一次 OpenAI 的 schema 微调导致前端大面积白屏——这就是混淆了“能力提供者”和“能力使用者”的边界。
2.3 工程基础设施:为什么 CI/CD 流程比代码本身更值得细读
一个项目是否具备工程素养,看它的 CI 流程比看它的 README 更准。该项目的.github/workflows/ci.yml是教科书级的防御性工程实践:
- name: Validate LLM Schema Compliance run: | # 从所有工具的 schema.json 生成统一 OpenAPI v3 spec npx @openapitools/openapi-generator-cli generate \ -i ./openapi.yaml \ -g typescript-axios \ -o ./generated/client # 用生成的 client 调用 mock LLM server,验证所有 tool call payload 符合预期 npm run test:llm-schema - name: End-to-End Planning Smoke Test run: | # 启动本地 mock 服务(模拟 LLM、DB、Tool) cargo run --bin mock-server & sleep 5 # 执行 5 个典型规划场景(查订单、改地址、申请退款、咨询物流、投诉) npm run e2e:planning -- --scenario=order-query npm run e2e:planning -- --scenario=address-update # 检查每个场景的 plan step 数量、tool call 序列、fallback 触发率关键点在于:所有测试都基于真实 LLM 的行为模式建模,而非理想化假设。他们的 mock LLM server 会按概率返回:
- 10% 的
Invalid JSON响应(测试 parser resilience) - 5% 的
Missing required field响应(测试 schema validation) - 2% 的
Tool name not found响应(测试 fallback 机制) - 0.1% 的
Infinite loop detected响应(测试 planning cycle detection)
这种“故意制造故障”的测试哲学,让团队在上线前就暴露了 73% 的潜在崩溃点。相比之下,很多项目 CI 只跑tsc && jest,结果一上生产就被 LLM 的随机性打蒙。
3. 核心细节解析与实操要点:从类型定义到错误处理的每一处深意
3.1 Rust 层:Plan枚举的设计如何消灭“幽灵状态”
在src/agent/core/planning.rs中,Plan的定义是整个系统稳定性的基石:
#[derive(Debug, Clone, Serialize, Deserialize)] pub enum Plan { /// 单步执行:直接调用工具 SingleStep(Step), /// 多步执行:按顺序执行步骤列表 Sequential(Vec<Step>), /// 条件分支:根据 LLM 输出选择分支 Conditional { condition: String, // LLM 输出的判断依据 branches: HashMap<String, Plan>, // 分支映射 default: Box<Plan>, // 默认分支 }, /// 并行执行:多个 Step 同时进行 Parallel(Vec<Step>), /// 等待用户输入后再继续 WaitForInput { prompt: String, timeout_ms: u64 }, /// 终止计划,返回结果 Done { result: serde_json::Value }, /// 触发降级策略 Fallback { strategy: FallbackStrategy, original_plan: Box<Plan> }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Step { ToolCall { tool_name: String, args: serde_json::Value, timeout_ms: u64, }, Wait { duration_ms: u64, }, Log { message: String, }, }这个设计的精妙之处在于:所有可能的执行路径都被枚举为 enum 变体,不存在“未定义状态”。当 Planner 接收到 LLM 的原始响应时,解析逻辑强制将字符串映射到上述 enum 之一:
impl TryFrom<serde_json::Value> for Plan { type Error = PlanParseError; fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> { // 1. 先尝试解析为 SingleStep(最简情况) if let Ok(step) = serde_json::from_value::<Step>(value.clone()) { return Ok(Plan::SingleStep(step)); } // 2. 尝试解析为 Sequential(数组形式) if let Some(arr) = value.as_array() { let steps: Result<Vec<_>, _> = arr .iter() .map(|v| serde_json::from_value(v.clone())) .collect(); if let Ok(steps) = steps { return Ok(Plan::Sequential(steps)); } } // 3. 尝试解析为 Conditional(对象形式,含 condition 字段) if let Some(obj) = value.as_object() { if obj.contains_key("condition") { // ... 完整解析逻辑 return Ok(Plan::Conditional { ... }); } } Err(PlanParseError::UnknownFormat) } }这意味着:只要Plan枚举定义存在,任何 LLM 返回的非法 JSON 都会在try_from阶段失败,抛出PlanParseError,进而触发全局错误处理流程。而不会出现“解析成功但结构错乱,导致后续执行时 panic”的情况。我在自己项目中曾用 Python 的dataclass模拟类似逻辑,结果因为Optional[Dict]的默认值陷阱,导致一个None被当作空 dict 传入,最终在step.tool_name访问时报AttributeError——Rust 的enum+Result组合,从根本上杜绝了这类问题。
3.2 TypeScript 层:工具接口的schema如何成为契约的锚点
在packages/tools/src/interfaces.ts中,ToolInterface的定义是 TypeScript 发挥威力的关键:
export interface ToolInterface { /** 工具唯一标识符,必须与 LLM prompt 中的 tool name 一致 */ readonly id: string; /** 工具功能描述,用于 LLM 的 tool description */ readonly description: string; /** 工具输入参数的 JSON Schema,用于 LLM 的 tool schema */ readonly schema: { type: 'object'; properties: Record<string, JsonSchema>; required: string[]; additionalProperties?: boolean; }; /** 执行工具的主逻辑 */ execute(input: Record<string, unknown>): Promise<unknown>; /** 输入校验函数,可在 execute 前进行业务规则检查 */ validateInput?(input: Record<string, unknown>): Promise<void | string>; }重点在schema字段:它不是一个字符串,而是一个可执行的类型定义。项目提供了generateToolSchema()工具函数,能自动从 TypeScript 接口生成符合 OpenAPI 规范的 JSON Schema:
// packages/tools/src/order-tool.ts interface OrderQueryInput { order_id: string; user_id?: string; // 可选字段 } const orderTool: ToolInterface = { id: 'query_order', description: '查询用户订单详情', schema: generateToolSchema<OrderQueryInput>({ order_id: { type: 'string', minLength: 12, maxLength: 20 }, user_id: { type: 'string', pattern: '^U[0-9]{8}$' }, }), async execute(input) { // 实际业务逻辑 } };generateToolSchema的实现会:
- 检查
OrderQueryInput的所有字段是否在 schema 中声明 - 将
string映射为{ type: 'string' } - 将
number映射为{ type: 'number' } - 将
optional字段自动添加到required数组的排除列表 - 将 JSDoc 注释中的
@minLength、@pattern等标签提取为 schema 约束
这样,LLM 在生成 tool call 时,看到的 schema 是精确的;前端在构建表单时,可以基于同一 schema 生成校验规则;后端在execute前,可以用ajv库进行 runtime 校验。一套定义,三处复用,彻底消灭了“前后端字段不一致”、“LLM 生成字段名拼错”、“前端传参类型错误”三大经典问题。
3.3 错误处理体系:为什么LLMError比NetworkError更重要
在src/llm/error.rs中,LLMError枚举的定义暴露了工程深度:
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum LLMError { /// LLM 服务不可达或超时 NetworkError { source: reqwest::Error, url: String }, /// LLM 返回非 2xx 状态码 HttpStatusError { status: u16, body: String }, /// LLM 返回 JSON 无法解析 ParseError { raw_response: String, source: serde_json::Error }, /// LLM 返回 JSON 结构不符合预期 schema SchemaValidationError { expected: String, actual: String, path: Vec<String>, }, /// LLM 返回的 tool name 在注册表中不存在 UnknownToolError { tool_name: String, available_tools: Vec<String> }, /// LLM 返回的 tool args 不符合 schema InvalidToolArgsError { tool_name: String, schema: String, args: serde_json::Value, }, /// LLM 响应中包含禁止词汇(安全过滤) ContentFilterError { filtered_words: Vec<String> }, /// LLM token 耗尽,无法完成响应 TokenLimitExceeded { max_tokens: u32, used_tokens: u32 }, }这个枚举的价值在于:它把 LLM 的“黑盒错误”翻译成了可操作的工程信号。当LLMError::SchemaValidationError发生时,系统会:
- 自动记录
expected和actual的 diff - 触发告警:“LLM schema drift detected on tool X”
- 启动自动化修复流程:对比最新 schema,生成 patch 提交 PR
而LLMError::TokenLimitExceeded会触发Plan的自动降级:将Sequential计划转为Conditional,优先执行高价值步骤,跳过低优先级步骤。这种基于错误类型的精准响应,远胜于笼统的 “LLM failed, retrying…” ——后者只会让问题在重试中恶化。
4. 实操过程与核心环节实现:从本地开发到生产部署的全链路细节
4.1 本地开发环境搭建:为什么cargo make比npm run更适合 AI Agent
该项目摒弃了传统的npm run dev,采用cargo make作为统一任务调度器。Makefile.toml的配置揭示了复杂度管理的智慧:
[tasks.dev] # 启动 Rust 编排服务(监听 3000) command = "cargo" args = ["run", "--bin", "orchestrator"] [tasks.dev.frontend] # 启动 TS 前端(监听 3001) command = "npm" args = ["run", "dev"] [tasks.dev.mock] # 启动 mock LLM server(监听 3002) command = "cargo" args = ["run", "--bin", "mock-llm"] [tasks.dev.all] # 并行启动所有服务,自动等待端口就绪 dependencies = ["dev", "dev.frontend", "dev.mock"]关键创新点在于dev.mock任务:它启动的mock-llm不是简单返回固定 JSON,而是基于真实 LLM 行为统计建模的仿真器。其配置文件mock-llm-config.yaml包含:
# 模拟不同 LLM 的失败率 providers: openai: parse_error_rate: 0.08 schema_mismatch_rate: 0.05 anthropic: parse_error_rate: 0.03 schema_mismatch_rate: 0.02 # 模拟 token 限制 token_limits: gpt-4: 8192 claude-2: 10000 # 模拟响应延迟分布(毫秒) latency_ms: mean: 1200 std_dev: 300 min: 400 max: 3500开发者本地运行cargo make dev.all时,看到的不是“一切顺利”的假象,而是真实的、带噪声的 LLM 交互体验。这迫使你在开发阶段就思考:当parse_error_rate达到 8% 时,你的 Planner 是否会陷入无限重试?当latency_ms超过 2s 时,前端是否显示 loading 动画?这种“把生产问题搬到开发环境”的理念,让团队在 PR 阶段就能发现 90% 的稳定性缺陷。
4.2 生产部署策略:为什么用 Docker Compose 而非 Kubernetes 启动 MVP
尽管项目最终会部署到 K8s,但其docker-compose.prod.yml的设计极具启发性:
version: '3.8' services: orchestrator: image: ghcr.io/xxx/orchestrator:v1.2.0 ports: ["3000:3000"] environment: - LLM_PROVIDER=openai - LLM_BASE_URL=https://api.openai.com/v1 - TOOL_REGISTRY_URL=http://tools:8080 depends_on: [tools, redis] tools: image: ghcr.io/xxx/tools:v1.2.0 ports: ["8080:8080"] environment: - REDIS_URL=redis://redis:6379 depends_on: [redis] redis: image: redis:7-alpine command: redis-server --save 60 1 --loglevel warning volumes: ["./redis-data:/data"] # 关键:LLM 代理层,用于统一监控和熔断 llm-proxy: image: ghcr.io/xxx/llm-proxy:v1.2.0 ports: ["3002:3002"] environment: - UPSTREAM_URL=https://api.openai.com/v1 - RATE_LIMIT=1000/hour - TOKEN_USAGE_TRACKING=true depends_on: [redis]llm-proxy服务是隐藏的工程亮点:
- 它拦截所有到 OpenAI 的请求,统一添加
X-Request-ID和X-Agent-ID - 它实时统计每个
tool_id的 token 消耗,当单日消耗超过阈值时,自动返回429 Too Many Requests - 它缓存高频 tool call 的响应(如
get_user_profile),降低 LLM 调用频次 - 它将原始 LLM 响应包装为标准格式:
{ "status": "success", "data": {...}, "usage": { "prompt_tokens": 120, "completion_tokens": 45 } }
这种“在应用层之上再加一层代理”的做法,让团队无需修改 Orchestrator 代码,就能实现:
- 快速切换 LLM 供应商(只需改
UPSTREAM_URL) - 精确的 token 成本核算(财务部门要的报表数据)
- 熔断降级(当 OpenAI 服务抖动时,proxy 可返回缓存或 fallback 响应)
4.3 监控与可观测性:为什么otel-collector配置比业务代码更重要
otel-collector-config.yaml的配置体现了对 AI Agent 特殊性的深刻理解:
receivers: otlp: protocols: http: processors: # 关键:LLM 专用处理器 llm-trace: # 自动提取 LLM 请求中的 tool_name、prompt_tokens、completion_tokens # 将 span 名称设为 "llm.call.<tool_name>" # 添加 span attribute: "llm.response.format" (json/xml/plain) # 添加 span attribute: "llm.error.type" (if error occurs) # 关键:Planning 专用处理器 planning-span: # 将 Plan 的每个 Step 生成独立 span # 设置 span status: "OK" / "ERROR" / "FALLBACK" # 添加 span event: "plan.step.start" / "plan.step.end" exporters: logging: log_level: debug prometheus: endpoint: "0.0.0.0:8889" service: pipelines: traces: receivers: [otlp] processors: [llm-trace, planning-span, batch] exporters: [logging, prometheus]这套配置让监控面板能回答关键问题:
- 哪个 tool 的 LLM 调用失败率最高?→ 查
llm.call.*的 error rate - Planning 的平均 step 数是多少?→ 统计
plan.step.*span 数量 - Fallback 策略触发频率如何?→ 查
plan.step.status为FALLBACK的比例 - Token 成本最高的 3 个场景是什么?→ 按
llm.usage.completion_tokens排序
我曾在一个项目中只监控 HTTP 5xx 错误,结果线上 LLM 的SchemaValidationError高达 15%,却因为没被归类为“错误”而无人知晓。直到接入llm-trace处理器,才真正看清系统的健康状况。
5. 常见问题与排查技巧实录:来自真实战场的 7 个血泪教训
5.1 问题:LLM 返回的 JSON 中字段名大小写不一致,导致 Rust 解析失败
现象:LLM 有时返回"user_id",有时返回"userId",serde_json::from_value报missing field 'user_id'。
排查路径:
- 查
llm-proxy日志,确认原始响应确实存在大小写混用 - 查
orchestrator的Plan::try_from日志,定位失败的具体 JSON 片段 - 查
tools的schema定义,确认期望字段名是user_id
根本原因:LLM 的 prompt 中未强制要求字段命名规范,且serde_json默认区分大小写。
解决方案:
- 在
ToolInterface.schema中添加additionalProperties: false,让 LLM 知道只接受指定字段 - 在 Rust 的
Deserialize实现中,使用#[serde(rename_all = "snake_case")]统一转换 - 在
llm-proxy中添加字段标准化中间件:将所有camelCase自动转为snake_case
实操心得:不要指望 LLM 遵守约定,要用工程手段强制它遵守。我们在
llm-proxy中加入的字段标准化逻辑,让SchemaValidationError降低了 62%。
5.2 问题:TypeScript 工具的validateInput异步校验导致 Planner 超时
现象:用户提交订单查询,Planner 等待validateInput返回,但数据库连接池耗尽,导致整个 Plan 卡住。
排查路径:
- 查
otel-collector的plan.step.*span,发现validateInput的 P99 耗时 8.2s - 查
tools服务的数据库连接数监控,确认连接池已满 - 查
orchestrator的 timeout 配置,发现 Planner 全局 timeout 为 10s
根本原因:validateInput被设计为同步校验,但实际调用了异步 DB 查询,且未设置 timeout。
解决方案:
- 将
validateInput改为同步纯函数(只做格式校验,如order_id.length > 12) - 将业务规则校验(如“用户是否存在”)移到
execute内部,并设置tokio::time::timeout - 在
orchestrator中为每个Step设置独立 timeout,而非全局 timeout
注意:AI Agent 的超时必须是“分步超时”,不是“全局超时”。一个步骤失败不应阻塞整个 Plan。
5.3 问题:Rust 的Arc<Mutex<>>在高并发下成为性能瓶颈
现象:当并发请求超过 200 QPS 时,orchestratorCPU 使用率飙升至 95%,但llm-proxy和tools负载正常。
排查路径:
- 用
cargo flamegraph生成火焰图,发现std::sync::Mutex::lock占比 42% - 查代码,定位到
src/agent/memory.rs中的SharedMemory结构 - 发现
SharedMemory::get和SharedMemory::set都在Mutex内部操作
根本原因:所有内存读写都通过同一把锁,形成串行瓶颈。
解决方案:
- 将
SharedMemory改为DashMap<String, Arc<serde_json::Value>>,利用分段锁 - 对
get操作使用DashMap::get(无锁读取) - 对
set操作使用DashMap::insert(分段写入) - 将高频访问的 memory key(如
user_profile)单独缓存到Arc<OnceCell<...>>
提示:Rust 的
Mutex不是银弹。在 AI Agent 场景中,“共享状态”应尽量减少,优先用消息传递(tokio::sync::mpsc)替代共享内存。
5.4 问题:TypeScript 的any类型在工具链中泛滥,导致类型安全失效
现象:tools包中大量使用any,导致generateToolSchema无法推导类型,schema 生成为空。
排查路径:
- 运行
npm run build,发现tsc无报错(因为noImplicitAny: false) - 查
packages/tools/src/index.ts,发现export const tools: ToolInterface[] = [...]中的tools被声明为any[] - 查
generateToolSchema源码,确认其依赖 TypeScript 的 AST 类型推导
根本原因:TypeScript 配置中noImplicitAny被禁用,且any被当作“快速解决类型问题”的捷径。
解决方案:
- 强制启用
noImplicitAny: true,并修复所有any声明 - 为动态工具创建
ToolRegistry类,用Map<string, ToolInterface>替代any[] - 在 CI 中添加
tsc --noEmit --strict检查,失败则阻断 PR
实操心得:TypeScript 的最大价值不在“写代码时”,而在“改代码时”。当你要修改一个工具的输入参数,如果类型是
any,你得 grep 全局找所有调用点;如果是精确类型,IDE 会直接标红所有不匹配的地方。
5.5 问题:LLM 的tool_choice参数被忽略,导致 Planner 无法控制调用时机
现象:LLM 总是主动调用工具,即使 Planner 要求它先思考。
排查路径:
- 查
llm-proxy的 request log,确认发送的tool_choice是"none" - 查 OpenAI 的 response,发现
tool_calls字段仍存在 - 查 OpenAI 文档,发现
tool_choice="none"仅在gpt-4-turbo中生效,gpt-4会忽略
根本原因:LLM 供应商的 API 行为不一致,且文档模糊。
解决方案:
- 在
llm-proxy中实现tool_choice的 vendor-specific 适配:- OpenAI
gpt-4: 用{"type": "function", "function": {"name": "none"}} - Anthropic: 用
{"type": "tool_use", "name": "none"}
- OpenAI
- 在
orchestrator中添加Plan的force_thinking标志,当设置时,强制在 prompt 中添加指令:“请先输出思考过程,不要直接调用工具”
注意:不要迷信 LLM 文档。必须用
llm-proxy的 request/response 日志,验证每个参数的真实效果。
5.6 问题:Docker 镜像体积过大(1.2GB),导致 CI 构建缓慢
现象:docker build耗时 12 分钟,占 CI 总时长 70%。
排查路径:
- 运行
docker history <image>,发现node_modules占 850MB - 查
Dockerfile,发现COPY . .在npm install之后 - 查
package-lock.json,发现devDependencies被打包进生产镜像
根本原因:Node.js 的devDependencies(如typescript,jest)被错误地包含在生产镜像中。
解决方案:
- 使用多阶段构建:
# 构建阶段 FROM node:18 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY . . RUN npm run build # 生产阶段 FROM node:18-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules CMD ["node", "dist/index.js"] - 将
devDependencies移到devDependencies字段,确保npm ci --only=production不安装它们
提示:AI Agent 项目的镜像优化,核心是分离“构建依赖”和“运行依赖”。Rust 的
cargo build --release产物是静态二进制,天然适合 Alpine,但 TypeScript 的node_modules必须精细化管理。
5.7 问题:CI 中e2e:planning测试偶发失败,难以复现
现象:e2e:planning在 CI 中失败率约 3%,本地 100% 通过。
排查路径:
- 查 CI 日志,发现失败时
mock-llm的latency_ms配置被覆盖为mean: 5000 - 查
.github/workflows/ci.yml,发现e2e:planning步骤未指定MOCK_LLM_CONFIG环境变量 - 查
mock-llm源码,确认其默认配置mean: 5000,而本地开发用的是mock-llm-config.yaml
根本原因:CI 环境缺少明确的 mock 配置,回退到不稳定的默认值。
解决方案:
- 在
ci.yml中为每个 e2e 测试步骤显式设置MOCK_LLM_CONFIG:- name: E2E Planning Test run: npm run e2e:planning env: MOCK_LLM_CONFIG: '{"latency_ms":{"mean":1200,"std_dev":300}}' - 在
mock-llm中添加启动检查:若未提供MOCK_LLM_CONFIG,则 panic 并提示缺失配置
实操心得:所有非确定性依赖(时间、网络、随机数)在 CI 中必须显式固化。AI Agent 的测试尤其如此,因为 LLM 的随机性会被放大。
6. 最后分享一个真实场景:我们如何用这套思路重构了一个濒临崩溃的客服 Agent
去年 Q3,我接手一个已上线半年的客服 Agent,