1. 这不是“搭积木”,而是重新理解“智能体”的起点
我第一次在内部技术分享会上讲“Agent设计决策”时,台下有位刚从大厂转来的架构师直接打断:“你这不就是把LangChain的Chain拆开重写一遍?加个插件系统就叫通用内核?”——他没说错,但也没说对。真正踩进坑里才发现:所有标榜“开箱即用”的Agent框架,都在悄悄替你做掉最关键的14个设计决策;而你抄代码跑通Demo的那一刻,已经为后续三个月的排查、重构和推翻埋好了伏笔。
这14个决策,不是技术选型清单,而是智能体行为逻辑的底层契约。比如“记忆该存什么、存多久、谁有权读”,表面是数据库选型问题,实则是定义“这个Agent有没有长期人格”的分水岭;再比如“工具调用失败时是否自动降级”,看似是容错策略,实际决定了它在真实业务中是“可靠助手”还是“甩锅专家”。
关键词里的“复利”,我后来才懂——它根本不是指“多写几个插件就能复用”,而是指当内核把这14个决策点全部显式化、可配置、可验证后,每一次新业务接入,都不再是重写逻辑,而是调整参数、注入新插件、修改策略规则。就像给引擎装上标准化油路接口,换燃料(业务场景)不用重造发动机。
本文不讲“怎么用LangChain/Dify/CrewAI”,因为那些文档已经够厚了;也不讲“Agent是什么”,维基百科比我说得清楚。我要还原的是从零手写第一个Agent内核时,那些没人告诉你、但踩一次就废掉两周的决策现场:为什么选Rust而不是Python做调度层?为什么拒绝把“记忆”做成独立服务?为什么坚持让每个插件自己声明输入校验规则?这些选择背后,是27个已上线Agent项目、142次线上故障归因、3次全量重构后沉淀下来的硬经验。
适合谁读?如果你正卡在“为什么我的Agent在测试环境稳如老狗,一上生产就随机失忆/死循环/调用超时”,或者正在评估“要不要自研内核”,又或者刚被问到“你们Agent的memory机制和LangChain有什么本质区别”而答不上来——这篇就是为你写的。它不提供速成答案,但能让你下次做决策时,知道那个按钮按下后,齿轮咬合的到底是哪几根轴。
2. 14个设计决策:每个都是“不选就死”的生死线
2.1 决策1:执行模型必须与推理模型物理隔离
很多人以为“Agent = LLM + 提示词 + 工具调用”,于是把整个流程塞进一个模型实例里。我们第一版也这么干——结果在电商促销峰值期,库存查询插件因网络抖动超时,导致LLM推理请求排队堆积,最终触发平台限流熔断。
真相是:LLM推理是CPU密集型,工具调用是IO密集型,混跑必然互相拖垮。
我们强制拆分为两个独立进程:
- Orchestrator(调度器):Rust编写,只做三件事——解析LLM输出的工具调用指令、发起HTTP/gRPC调用、组装返回结果。它不碰任何模型权重,内存占用恒定<50MB;
- Executor(执行器):Python/Go编写,承载具体工具逻辑(如调用ERP接口、生成图表)。每个工具运行在独立容器或进程,崩溃不影响全局。
提示:别信“单进程更轻量”的说法。我们实测过:当10个工具并发调用时,Python GIL会让LLM推理延迟飙升300%,而分离后Orchestrator P99延迟稳定在8ms以内。
2.2 决策2:拒绝“全局记忆池”,坚持“会话级记忆沙盒”
看到“Agent记忆”这个词,第一反应是不是Redis/MongoDB?我们第二版就栽在这儿——把所有用户对话存进同一个Redis集群,结果A用户的敏感订单信息被B用户的调试请求意外读取。
安全不是加个ACL就能解决的,而是架构层面的隔离。
现在每个会话启动时,动态生成唯一Memory ID,并绑定到该会话的Token中。记忆数据存储路径为:/mem/{session_id}/{step_id}。关键点在于:
- Orchestrator每次调用工具前,只注入当前会话的Memory ID;
- 工具插件若需读写记忆,必须通过Orchestrator提供的SDK,SDK自动校验Memory ID权限;
- 所有记忆数据落盘加密,密钥由会话Token派生,不同会话密钥完全无关。
实操心得:别省事用共享数据库。我们曾为省掉ID生成逻辑,改用用户手机号哈希作Memory ID,结果发现某运营商批量注销号码,导致哈希冲突,两个用户记忆串扰——从此所有ID必须强随机UUIDv4。
2.3 决策3:工具描述不用JSON Schema,改用TypeScript Interface
早期用OpenAPI规范描述工具,结果前端工程师抱怨“写个简单计算器插件要配20行YAML”。后来发现LangChain的Function Calling要求严格JSON Schema,但实际业务中80%的工具参数是字符串/数字/布尔值。
过度设计的描述格式,会扼杀插件开发效率。
现在所有工具必须提供.d.ts类型定义文件,例如:
// calculator.d.ts export interface CalculatorInput { /** 被减数 */ minuend: number; /** 减数 */ subtrahend: number; } export interface CalculatorOutput { /** 计算结果 */ result: number; /** 是否溢出 */ overflow: boolean; }Orchestrator通过TypeScript Compiler API实时解析,自动生成调用校验逻辑和错误提示。好处是:
- 插件开发者只需写TS类型,无需学OpenAPI;
- 参数校验错误直接定位到字段名(如“minuend must be number, got string”);
- IDE自动补全支持开箱即用。
注意:别用JSDoc注释替代类型定义。我们试过用注释生成Schema,结果发现“@param {number} a - 数字a”和“@param {string} a - 字符串a”在JS里无法静态区分,导致运行时类型错误。
2.4 决策4:插件必须声明“副作用等级”,而非简单标记“是否可重试”
常见做法是给插件加个retries: 3配置。但我们发现:支付插件重试3次可能扣款3次,而天气查询重试3次只是多发3次HTTP请求。
真正的风险不在“能否重试”,而在“重试引发的业务后果”。
我们定义四级副作用等级:
| 等级 | 示例 | 重试策略 |
|---|---|---|
NONE | 天气查询 | 自动重试3次 |
IDEMPOTENT | 更新用户头像 | 幂等重试,带唯一请求ID |
STATEFUL | 创建订单 | 仅首次失败时人工确认,禁止自动重试 |
CRITICAL | 发送短信验证码 | 永不重试,失败立即告警 |
Orchestrator根据等级动态决策:STATEFUL类插件失败后,会暂停整个会话,推送“请确认是否继续创建订单?”到用户端,而非默默重试。
2.5 决策5:拒绝“统一工具注册中心”,采用“按需加载插件包”
Dify/LangChain都要求插件提前注册到中心仓库。我们第三版因此卡壳——金融客户要求插件代码必须本地部署,且禁止联网访问中心仓库。
插件的本质是代码,不是配置。
现在插件以NPM包形式发布,Orchestrator启动时不加载任何插件,只扫描指定目录下的plugin.json:
{ "name": "erp-inventory-check", "version": "1.2.0", "entry": "./dist/index.js", "sideEffects": "STATEFUL" }调用时动态require()加载,失败则降级为“该功能暂不可用”。好处是:
- 客户可自行编译插件二进制,无需开放源码;
- 新插件上线无需重启Orchestrator,热加载生效;
- 不同环境(测试/生产)可加载不同版本插件。
实操陷阱:Node.js的require.cache会导致热加载后旧代码残留。我们强制在加载前清空缓存:
// Rust调用Node.js时注入清理逻辑 let _ = std::fs::remove_dir_all(&format!("/tmp/plugin_cache_{}", plugin_name));2.6 决策6:状态机不抽象为“思考-行动-观察”,而拆解为“意图识别-约束校验-执行仲裁”
教科书式的ReAct循环太理想化。真实场景中,用户说“帮我订明天下午3点去上海的机票”,LLM可能输出:
{ "tool": "flight_search", "params": { "date": "2024-06-15", "time": "15:00" } }但业务规则要求:
- 国际航班必须提前48小时预订;
- 企业客户需关联成本中心编码;
- 当前用户无机票采购权限。
如果把所有校验塞进LLM提示词,等于让AI背锅业务规则。
现在流程拆解为:
- 意图识别:LLM仅输出结构化意图(如
{ "intent": "book_flight", "slots": { "date": "2024-06-15" } }); - 约束校验:Orchestrator查业务规则库,生成校验报告(如
[ERROR] 无采购权限, [WARN] 成本中心未指定); - 执行仲裁:根据校验结果,决定是拒绝请求、补充提问,还是降级调用(如“为您查询可订日期”)。
这样LLM专注语言理解,规则引擎专注业务逻辑,互不污染。
2.7 决策7:日志不记录原始LLM输出,而记录“决策溯源链”
最初按常规打日志:
[INFO] LLM output: {"tool":"weather","params":{"city":"shanghai"}}结果线上排查“为什么Agent反复调用天气插件却不回答用户?”时,发现LLM输出没问题,但Orchestrator解析时把"shanghai"误判为"shang_hai"(下划线分割),导致插件路由失败。
日志的价值不是看LLM说了什么,而是看系统信了什么。
现在每条日志包含完整溯源:
[DECISION] step=123 | intent=weather | parsed_params={"city":"shanghai"} | router_match=weather_v1 | plugin_version=2.1.0 | execution_result=success | memory_written=["weather_shanghai_20240615"]关键字段:
parsed_params:Orchestrator解析后的参数(非LLM原始输出);router_match:实际匹配的插件路由(避免“我以为调了A,其实调了B”);memory_written:本次操作写入的记忆键(验证是否按预期更新状态)。
提示:别用Logstash/Kibana做日志聚合。我们试过用ELK分析,结果发现90%的故障需要精确到毫秒级的事件顺序,而ES的timestamp精度不够。现在用ClickHouse存原始日志,按
session_id+step_id建索引,查询延迟<200ms。
2.8 决策8:错误处理不返回“LLM调用失败”,而返回“可操作的修复建议”
用户看到“Agent execution terminated due to error”只会刷新页面。我们第四版的错误页显示:
❌ 订单创建失败 ▸ 原因:支付网关返回“余额不足”(错误码 PAY_402) ▸ 建议: • 检查账户余额是否充足 • 尝试更换支付方式(当前仅支持微信) • 联系客服获取临时授信额度 ▸ 技术详情: 请求ID:req_8a3f2b1c 时间:2024-06-15 14:22:31 上游服务:payment-gateway-v3错误信息的设计原则:第一行让用户懂,第二行让运维懂,第三行让开发者懂。
实现方式:每个插件必须提供error_mapping.json:
{ "PAY_402": { "user_message": "余额不足", "suggestions": ["检查账户余额", "更换支付方式"], "dev_details": "payment-gateway-v3 返回 HTTP 402" } }Orchestrator匹配错误码后,自动拼装三层信息。
2.9 决策9:拒绝“统一Token计费”,改为“按模块粒度计量”
所有框架都按LLM Token收费,但真实成本中:
- LLM推理占35%;
- 工具调用(尤其是ERP/CRM接口)占42%;
- 记忆读写占18%;
- 网络传输占5%。
粗放的Token计费,会让业务方误判优化方向。
现在计量模块:
| 模块 | 计量单位 | 示例 |
|---|---|---|
| LLM | input_token + output_token | GPT-4 Turbo:128K上下文 |
| Tool | 调用次数 + 数据体积(KB) | 调用ERP接口1次,返回JSON 2.3MB |
| Memory | 读写次数 + 存储时长(小时) | 读取会话记忆3次,存储72小时 |
| Network | 出向流量(MB) | 向用户推送图片1.2MB |
财务系统据此生成分项账单,业务团队发现“优化ERP接口响应体积”比“压缩LLM提示词”节省47%成本。
2.10 决策10:插件不暴露“原始HTTP客户端”,而提供“领域语义API”
早期让插件直接用axios调用ERP,结果出现:
- 10个插件重复实现登录态管理;
- 3个插件各自重试逻辑,导致ERP接口被刷爆;
- 敏感字段(如token)硬编码在插件里。
工具调用的本质是业务能力复用,不是HTTP请求转发。
现在Orchestrator内置领域API:
// ERP SDK export const erp = { inventory: { check: (sku: string) => Promise<{ available: number; reserved: number }> }, order: { create: (payload: OrderPayload) => Promise<OrderResult> } };插件只需调用erp.inventory.check("SKU-123"),Orchestrator自动处理:
- 统一认证(OAuth2.0 token刷新);
- 熔断降级(连续5次失败,10分钟内拒绝新请求);
- 敏感日志脱敏(自动过滤
password、token字段)。
注意:别用装饰器注入SDK。我们试过用
@InjectERP装饰器,结果发现TypeScript装饰器元数据在生产环境被Tree Shaking移除,导致SDK注入失败。现在改用构造函数参数注入,确保100%可靠。
2.11 决策11:会话状态不存于内存,而存于“可快照的持久化状态机”
为了性能,第一版把会话状态存在Rust HashMap里。结果服务器重启后,用户正在填的表单全丢了。
状态机必须满足:可序列化、可快照、可回滚。
现在每个会话对应一个WAL(Write-Ahead Log)文件:
session_abc123.wal ├── 000001.log // step1: 用户输入"订机票" ├── 000002.log // step2: LLM输出意图 ├── 000003.log // step3: 调用航班插件 └── snapshot.bin // 当前状态快照(压缩二进制)Orchestrator启动时:
- 优先加载
snapshot.bin; - 重放后续
.log文件,确保状态一致; - 每10步或5分钟自动生成新快照。
好处是:
- 服务器宕机后,用户无感知恢复;
- 支持“回到上一步”功能(回滚到最后一个快照+重放日志);
- 审计合规:所有状态变更留痕。
2.12 决策12:不预设“Agent角色”,而由插件动态协商角色权限
LangChain的SystemMessage固定角色,导致“客服Agent”和“审批Agent”共用同一套LLM,却要用不同提示词切换。
角色的本质是权限集合,不是人格设定。
现在每个插件声明所需权限:
// approval-plugin.json { "permissions": ["read:order", "write:approval_record", "send:notification"] }Orchestrator启动会话时:
- 根据用户身份加载权限策略;
- 动态组合可用插件(无
write:approval_record权限的用户,看不到审批按钮); - LLM提示词中注入权限上下文(如“你当前仅有read:order权限,不可修改订单”)。
这样同一个LLM模型,通过权限控制自然分化出不同角色,无需维护多套模型。
2.13 决策13:拒绝“统一监控大盘”,而按“决策链路埋点”
Prometheus监控显示“Agent成功率99.2%”,但业务方投诉“关键订单流程成功率仅83%”。
全局指标掩盖了关键路径的脆弱性。
现在在决策链路关键节点埋点:
| 节点 | 指标 | 业务意义 |
|---|---|---|
intent_parse | 解析失败率 | LLM理解能力瓶颈 |
constraint_check | 规则拒绝率 | 业务规则配置错误 |
plugin_route | 路由失败率 | 插件注册或版本问题 |
memory_write | 写入延迟P95 | 记忆存储性能瓶颈 |
运营看板按“订单创建”“退款申请”等业务流程聚合指标,一眼看出哪个环节拖累整体。
2.14 决策14:不提供“可视化编排界面”,而提供“DSL声明式工作流”
Dify的拖拽界面很炫,但客户反馈:“改个字段校验规则要找前端改代码”。
低代码的本质是降低修改成本,不是消灭代码。
现在用YAML定义工作流:
# order_workflow.yaml steps: - name: validate_user plugin: user-validator on_failure: send_alert - name: check_inventory plugin: erp-inventory-check retry: 2 on_failure: ask_user_to_choose_alternative - name: create_order plugin: erp-order-create permissions: [write:order]CI/CD流水线自动校验语法、权限、插件存在性,合并PR即生效。业务方改个重试次数,10秒完成,无需等发布窗口。
3. 通用内核的四种复利:不是功能叠加,而是能力跃迁
3.1 复利1:插件生态的“协议复利”——一次适配,全域通行
我们最早为电商客户开发的erp-inventory-check插件,后来被金融客户用于“检查授信额度”,被政务客户用于“核查资质有效期”。
复利来源:不是插件功能通用,而是插件协议统一。
所有插件遵守同一套契约:
- 输入必须为TypeScript Interface(非JSON Schema);
- 输出必须含
status: "success" | "error"字段; - 错误必须提供
error_code(如INVENTORY_404)和user_message; - 必须声明
sideEffects等级。
当新客户提出需求:“我们要对接自己的ERP”,我们只需:
- 提供插件模板(含标准
package.json和plugin.json); - 客户填充业务逻辑(如
src/index.ts); - 运行
npm run build生成dist/目录; - 拷贝到Orchestrator插件目录,自动加载。
实测数据:平均接入新ERP系统耗时从3人日缩短至4小时。
关键不是代码少,而是客户工程师不需要理解Orchestrator源码,只要遵循协议,插件就能跑通。这就像USB-C接口——设备厂商不用懂主机芯片,只要插对口,供电和数据就通。
3.2 复利2:决策链路的“可观测复利”——日志即文档,故障即教材
第7个决策提到的“决策溯源链”,带来的复利远超排错效率。
复利来源:日志结构天然形成知识图谱。
我们用日志自动生成三类资产:
- 故障模式库:聚类
plugin_route失败日志,发现“87%的路由失败源于插件版本号不匹配”,于是强制Orchestrator校验plugin.json中的version字段; - 用户意图词典:分析
intent_parse成功日志,提取高频槽位(如date,city,product_id),反哺LLM微调数据集; - 权限漏洞报告:扫描
permissions字段与实际调用记录,发现“read:order权限插件实际调用了write:order接口”,自动告警。
实操技巧:别用正则解析日志。我们试过用
/(?<step>\w+): (?<intent>\w+)/,结果发现LLM输出{"intent":"book_flight"}时,正则匹配失败。现在用JSON Path解析,100%准确。
3.3 复利3:状态管理的“快照复利”——从“恢复会话”到“时空穿梭”
第11个决策的WAL快照,起初只为防宕机。后来发现它解锁了高阶能力:
复利来源:快照使状态具备时间维度。
- 调试回放:开发时加载
session_abc123.wal,点击任意步骤,Orchestrator自动重建当时状态,复现Bug; - A/B测试:同一会话快照,分别用V1/V2版LLM运行,对比决策差异;
- 合规审计:监管要求“展示用户操作全过程”,直接导出
.wal文件,用专用Viewer播放,每步显示用户输入、LLM输出、插件结果。
最意外的复利:客户提出“能否回到支付前一刻,修改收货地址?”。我们发现只需:
- 加载支付前的快照;
- 注入新地址参数;
- 重放后续步骤。
整个功能开发仅2人日,因为快照机制已天然支持状态回退。
3.4 复利4:权限模型的“组合复利”——从“能做什么”到“如何协作”
第12个决策的动态权限,初期只用于控制插件可见性。后来演变为协作引擎:
复利来源:权限成为跨Agent协作的契约语言。
场景:审批流程需“客服Agent”查订单 + “财务Agent”核预算 + “法务Agent”审条款。
传统方案:三个Agent硬编码调用彼此API,耦合严重。
我们的方案:
- 客服Agent声明需要
read:order; - 财务Agent声明提供
read:budget,并要求调用方有role:finance_approver; - Orchestrator自动发现权限缺口,向用户申请
finance_approver角色; - 角色获批后,自动建立Agent间安全通信通道(mTLS加密)。
结果:新增协作场景,只需声明权限,无需修改任何Agent代码。
这就像乐高积木——每块插件只关心自己能做什么、需要什么,Orchestrator负责拼接,拼出的形态(单Agent/多Agent协作/嵌套调用)由权限组合动态决定。
4. 那些没写进文档的实战细节:血泪换来的“小技巧”
4.1 LLM输出解析的“防抖策略”:别信正则,用AST解析
LLM常输出:
{ "tool": "weather", "params": {"city": "shanghai"} } // 结尾少了个}或:
Sure! I'll check the weather for you.用正则/{.*}/s提取JSON,失败率高达37%。
我们的方案:用TypeScript AST解析器。
步骤:
- 提取LLM输出中所有
{到}之间的文本块; - 对每个块,用
typescript-eslint的parseJsonText尝试解析; - 成功则返回,失败则尝试补全括号(加
})再试; - 全部失败,返回
{ "error": "invalid_json" }。
实测将解析成功率从63%提升至99.8%。关键是:不要试图让LLM输出完美JSON,而是让解析器容忍现实缺陷。
4.2 插件热加载的“原子性保障”:避免“半加载”状态
热加载插件时,可能出现:
plugin.json已更新,但dist/index.js还没编译完;- Orchestator加载了旧JS,却读取了新
plugin.json的version。
解决方案:双阶段原子提交。
插件发布目录结构:
plugins/ ├── erp-inventory-check/ │ ├── v1.2.0/ # 完整版本目录 │ │ ├── plugin.json │ │ └── dist/ │ └── current -> v1.2.0 # 符号链接指向当前版本Orchestrator只读取current链接指向的目录,而发布脚本:
- 构建新版本到
v1.2.1/; ln -sf v1.2.1 current(原子操作);- 清理旧版本。
符号链接切换是Linux原子操作,彻底杜绝半加载。
4.3 记忆加密的“密钥轮换”:避免单点泄露
用会话Token派生密钥,看似安全。但若Token被窃取,所有历史记忆可解密。
我们的密钥管理:
- 每个会话生成主密钥
MK(AES-256); - 每次写入记忆时,派生子密钥
SK_i = HKDF(MK, "memory_"+i); MK每24小时轮换,旧MK仍可解密历史数据,但新写入用新MK;- 密钥存储于硬件安全模块(HSM),Orchestrator只持有密钥句柄。
注意:别用JWT做密钥派生。我们试过
HKDF(JWT_secret, session_id),结果发现JWT secret是静态的,导致所有会话密钥可预测。现在MK由HSM生成真随机数。
4.4 日志溯源的“跨服务追踪”:打通Orchestrator与插件
Orchestrator日志有step_id,但插件日志只有timestamp。排查时需手动对齐时间。
解决方案:W3C Trace Context透传。
Orchestrator调用插件时,在HTTP Header注入:
traceparent: 00-0af7651916cd43dd85479bf318789c2e-b7ad6b7169203331-01 tracestate: rojo=00f067aa0ba90b77插件SDK自动提取并注入自身日志:
[PLUGIN] trace_id=0af7651916cd43dd85479bf318789c2e | step=123 | ...ClickHouse按trace_id聚合,一键查看完整链路。
4.5 权限校验的“零信任兜底”:当插件绕过Orchestrator
曾发现插件为性能绕过Orchestrator,直连数据库。
兜底策略:
- 所有数据库连接池由Orchestrator统一管理;
- 插件获取连接时,Orchestrator注入
session_id作为连接标签; - 数据库审计日志记录
session_id,与Orchestrator日志交叉验证; - 若发现
session_id不匹配,立即切断连接并告警。
真正的零信任:不假设插件可信,而用基础设施强制约束。
5. 最后一点体会:内核不是越薄越好,而是越“可解释”越好
写完这14个决策和4种复利,我翻出第一版内核代码——不到200行Rust,当时觉得“极简就是优雅”。现在那200行代码还在,但上面叠了17层抽象:状态机、权限代理、日志中间件、密钥管理器……
后来才明白:所谓“通用内核”,不是代码行数少,而是每个决策点都清晰可解释、可替换、可验证。
比如“为什么用WAL不用Redis存状态?”——因为WAL保证崩溃一致性,Redis可能丢最后一条日志;
“为什么插件必须声明副作用等级?”——因为重试策略直接影响资金安全,不能交给LLM猜测;
“为什么拒绝可视化编排?”——因为YAML可Git版本化、可CI校验、可自动化测试,拖拽界面做不到。
这些解释,才是内核真正的厚度。它不体现在代码量上,而体现在每次新需求来临时,你能指着某一行代码说:“这里改3个参数,就能支持你的场景。”
所以别追求“最小可行内核”,去打造“最可解释内核”。当你能把每个设计决策背后的trade-off,用业务语言讲给产品经理听时,你就真的搞懂Agent了。