多Agent架构设计与OpenClaw编排:从消息协议到状态机实践
1. 从单Agent到多Agent什么时候该拆、什么时候不该拆先说个结论多Agent架构不是银弹甚至大部分业务场景里你只需要一个Agent加一堆好用的工具。TS/JS技术栈开发智能体最大的优势是生态统一——一套语言能同时覆盖Node服务端、浏览器端、甚至Electron桌面端类型系统让Agent之间的消息协议变得可维护。但很多人一上来就想搞多Agent协同结果把简单的业务拆成了分布式灾难。我这里说的“多智能体”指的是一组具备独立职责、可以互相发送消息、共享工具和记忆的Agent实例通过某种编排策略共同完成一个复杂任务。它和“多个函数”有本质区别Agent之间传递的不是返回值而是语义化的“消息”Agent自己决定如何响应这些消息。这个差异带来灵活性也带来失控风险。什么时候真的需要多Agent我自己的判断标准有三条任务的子目标之间有明显的领域隔离比如“客服工单处理”里的意图识别、信息提取、应答生成、质检复核这些子任务各有各的Prompt和工具偏好塞进一个Agent会让上下文互相干扰。单个Agent需要同时掌握的工具太多工具描述挤占了太多上下文窗口导致主任务推理质量下降。这时候按职责拆分每个Agent只看到自己需要的工具反而省Token。需要审计和权限隔离。比如一个Agent只能读数据库、另一个Agent才能写数据库用代码硬隔离比在工具函数里写权限判断要可靠得多。反过来什么时候不该拆如果任务是一个连贯的文本生成流程拆成多个Agent只会增加延迟、放大幻觉、消耗你排查问题的精力。我见过有人把一个“写周报”的功能拆成三个Agent结果三个人格互相较劲输出还不如一个Agent配两条指令来得稳。多Agent的复杂度是边际递增的。单Agent是“一个人干活”多Agent是“开一家公司”——你要设计岗位、定义汇报线、处理冲突、防止甩锅。所以在动手搭建OpenClaw这类编排框架之前先把拆分的理由写清楚哪怕只写给自己看也比拍脑袋决定要强。2. Agent核心抽象消息、工具、记忆与路由2.1 消息协议别用JSON字符串直接丢TS/JS里做多Agent第一步不是选框架而是定义消息类型。我见过太多项目直接把模型输出的JSON字符串丢给下一个Agent看起来能跑但只要字段一变化整个链路崩溃而且报错信息根本没法看。正确的做法是把消息定义为强类型的可辨识联合Discriminated Union。type AgentMessage | { id: string; type: text; role: agent | user | system; content: string; ts: number } | { id: string; type: tool_call; toolName: string; args: Recordstring, unknown; ts: number } | { id: string; type: tool_result; toolCallId: string; ok: boolean; data: unknown; ts: number } | { id: string; type: status; state: started | running | done | failed; detail?: string; ts: number };type字段是判别属性让TypeScript能在编译期帮你检查消息分支。tool_call和tool_result成对出现靠toolCallId关联这样即使Agent并行发出多个工具调用响应也能对得上。ts字段别省多Agent系统排查问题全靠时间线。这里面有个容易踩的坑单个Agent的上下文窗口是有限的但你传给它的消息列表不能不限增长。所以消息协议里必须带ts同时每个Agent内部要做“上下文压缩”——把太旧的tool_call/tool_result对折叠成一句摘要只保留text类型的系统记忆。这不是优化是保命。2.2 工具注册让模型看得懂、调得对TS/JS生态里做工具调用主流方案是让模型输出一个JSON Schema然后由运行时帮它调用真实函数。这里的关键不是写函数本身而是把“工具描述”写得让模型能在模糊场景下做对选择。interface ToolDefinitionArgs unknown { name: string; description: string; parametersSchema: Recordstring, unknown; execute: (args: Args, context: ToolContext) Promiseunknown; } const queryOrderTool: ToolDefinition{ orderId: string } { name: query_order, description: 根据订单号查询订单状态、物流信息和售后进度。当用户提到“我的订单”“物流”时使用。, parametersSchema: { type: object, properties: { orderId: { type: string, description: 订单号通常以SO开头 }, }, required: [orderId], }, async execute({ orderId }, ctx) { // 实际的数据库查询逻辑 }, };description字段要写“触发条件”不要只写“查订单”。模型是靠语义相似度选择工具的你写“当用户提到订单时使用”比写“查询订单详情接口”要准确得多。另外所有工具函数尽量做成纯函数依赖通过ToolContext注入——测试的时候直接Mock这个上下文就行。2.3 记忆分层短期工作区、长期档案、共享黑板多Agent系统里记忆最容易变成一团乱麻每个Agent都有自己的历史、又有共享状态、还有外部存储的持久化记录。我的方案是分三层每层用不同的机制管理短期记忆就是当前对话窗口的消息列表每个Agent独立持有一轮任务结束后即可丢弃。长期记忆是持久化的用户或任务档案用向量检索或者结构化存储Agent按需主动查询。共享记忆是任务级的“黑板”存当前任务的分阶段结果、临时结论、待办事项多个Agent都能读写但必须通过框架层加锁避免并发写覆盖。OpenClaw这类编排框架好就好在把“共享黑板”内置了——你不用自己在业务代码里维护一个全局Map然后靠人肉纪律防止互相覆盖。后面我会详细写它的设计。2.4 编排模式负责任务分发的是人还是Agent最常见的编排方式有三种我用自己的话翻译一下路由器模式一个路由Agent读用户输入决定把任务交给哪个专家Agent处理。适合意图分明的场景比如先判断“这是售后还是售前”再分派。流水线模式任务按固定顺序经过多个Agent前一个的输出是后一个的输入。适合有严格流程的阶段式任务但中间只要一个Agent掉链子整条线卡死。协议模式没有中心调度Agent们通过消息互相协商像一群人开会。灵活性最高但调试成本像宇宙那么高没有充分理由别用。我自己最常用的是“路由器有限状态机”的组合——由编排层定义一个任务状态机比如pending、extracting、analyzing、reviewing、done状态流转由路由Agent的结果触发但状态本身存在框架里而不是靠Prompt维持。这样既保留了Agent的决策能力又把流程控制权握在确定性代码手里。3. OpenClaw的设计思路模型无关的Agent编排层3.1 它解决的核心问题是什么OpenClaw这个名字听起来像某个带钩子的猛兽实际它是一个基于TS/JS的Agent编排层框架目标是把“多个Agent协作跑一个业务任务”这件事变成可配置、可观察、可恢复的工程实践。它不是某家大厂的魔改产物更像社区里沉淀出来的一套模式集合我第一眼看到它的设计文档时脑子里冒出来的想法是“这玩意儿解决了多Agent项目最痛的几个问题”。最痛的问题是什么是Agent的调用方和模型提供商绑得太死。今天用某个闭源模型跑通了流程明天想换成开源模型或者从云厂商A切到云厂商B可调的代码可能散落在十几个文件里。OpenClaw做了一个模型适配层所有Agent只跟框架定义的ChatCompletion接口打交道模型供应商通过配置文件切换。简单说你的业务代码写一次模型随便换。第二件事是会话与任务生命周期管理。普通单Agent项目里“一次对话”就是“一次请求-响应”但多Agent项目里任务要跨多个Agent、耗时可能几十秒甚至几分钟期间会话状态需要持久化。OpenClaw用它内部定义的Session和Task实体管理生命周期断网、重启、超时之后任务还能恢复。3.2 核心组件拆解如果只是粗看OpenClaw的组件并不复杂核心就五个概念Director导演是整个编排入口接收外部请求创建Task维护状态机转换。Worker工人是真正干活的最小Agent单元每个Worker有独立的System Prompt、模型配置和工具集。Registry注册中心管理所有Agent和ToolDefinitions的注册表Director只能调用Registry里登记过的Worker。Bus总线是消息分发机制Worker之间可以发布/订阅事件事件也会记录到Session时间线里用于回放和审计。Store仓库负责持久化Session快照、Task状态、共享黑板数据。默认有内存实现也可以替换成Redis或关系型数据库。这套设计把几个容易纠缠在一起的关注点拆开了Agent只管推理和调用工具Director管流程Bus管通信Store管状态。每个组件都可以单独替换。听起来像微服务那套但它压缩到进程内代价小得多。3.3 一个最小可跑的OpenClaw项目光说不练不行我写一个最小化的OpenClaw风格编排Demo。假设我们有一个简单的意图分类任务输入一句用户话术由“分类Agent”判断意图然后交给“响应Agent”生成回复。import { OpenClaw, Agent, Tool, defineTool } from openclaw; const claw new OpenClaw({ store: new MemoryStore(), defaultModel: { provider: openai-compatible, model: gpt-4o-mini }, }); const classifyAgent new Agent({ name: classifier, system: 你是意图分类器。根据用户输入只输出一个词售后、售前、其他。, tools: [], }); const respondAgent new Agent({ name: responder, system: 你是客服代表。根据用户的输入和已分类的意图给出简短回复。, tools: [queryOrderTool, refundPolicyTool], }); claw.register(classifyAgent); claw.register(respondAgent); export async function handleChat(input: string) { const task await claw.createTask(customer_service); const intent await task.callWorker(classifier, { input }); const reply await task.callWorker(responder, { input, intent: intent.content.trim(), }); await task.complete(reply.content); return reply.content; }task.callWorker是同步阻塞式调用适合流水线模式。如果要并行调用多个Worker用task.spawn()拿到子任务句柄等Promise.all。这个例子里有个细节分类Agent的输出没有走复杂的结构化协议而是用一个裸字符串——因为意图就一个词没必要为难模型输出JSON。多Agent系统里能简单就简单任务之间协议越薄越不容易断。4. 实操从零搭一个可用的多Agent客服工单系统4.1 明确功能和技术选型这一节写一个稍微完整一点的案例一个客服工单系统用户提交问题系统自动完成意图识别、信息抽取、方案生成、人工复核四步。这个案例不是玩具。信息抽取Agent要调用数据库查订单方案生成Agent要根据售后政策库决定退款还是换货质检Agent要检查方案里有没有“承诺24小时到账”这种违规话术。每个Agent都有自己的工具和Prompt全部用TS/JS实现。技术栈我用普通的Express暴露HTTP接口OpenClaw做编排SQLite做持久化存储。没有引入重型消息队列——进程内事件总线够用了等量上来再拆RabbitMQ之类的不迟。功能拆成四条线接收线HTTP接口收工单创建Task。处理线意图识别 → 实体抽取 → 方案匹配 → 结果生成。质检线方案生成后质检Agent跑一遍合规检查不合格就打回重做。审计线所有消息、状态流转、工具调用记录存Store方便事后复盘。4.2 关键实现任务状态机与Worker私有上下文下面是核心代码的骨架我删掉了一些业务细节保留跟多Agent强相关的部分。要注意的点我都写了注释。// state-machine.ts — 用有限状态机管Task生命周期 export type WorkflowState created | classifying | extracting | resolving | reviewing | done | failed; const transitions: RecordWorkflowState, WorkflowState[] { created: [classifying], classifying: [extracting, failed], extracting: [resolving, failed], resolving: [reviewing, failed], reviewing: [done, resolving, failed], // 不合格就回到resolving done: [], failed: [], }; export function assertTransition(from: WorkflowState, to: WorkflowState) { if (!transitions[from].includes(to)) { throw new Error(非法状态转移: ${from} - ${to}); } }状态机不是为了秀技术而是为了防呆。如果没有状态约束一个“处理中”的工单可能被重复调度三次——我见过这种线上事故用户收到三条回复每条语气还不一样。Worker的私有上下文也必须提一下。每个Agent实例在工作时不能直接读黑板上其他Agent的中间结果要由Director决定哪些字段对哪个Worker可见。这个看起来“多此一举”的隔离实际避免了很多Prompt注入问题。比如用户原始输入里有“忽略以上指令”这种话术如果每个Agent都看得到攻击面就大了。4.3 并行Agent与结果聚合的陷阱工单系统里有一个合理的并行场景抽取Agent需要同时查订单、查售后政策、查历史工单。三个查询互相不依赖串行执行太慢所以用并行调度。const [orderRes, policyRes, historyRes] await Promise.all([ task.spawnWorker(extractor, query_order, { orderId }), task.spawnWorker(extractor, query_policy, { category }), task.spawnWorker(extractor, query_history, { userId }), ]);这个用法有个容易忽略的坏味道三个spawn用的是同一个Worker类型extractor但传入的工具参数不同。模型在处理并行调用时偶尔会把上下文搞混——比如把订单号当成政策编号填进另一个工具。我在生产环境里的做法是给每个并联分支绑定独立的Prompt片段让模型清楚知道“本次调用只负责查询某类信息别搭理其他字段”。哪怕用同一个模型上下文隔离靠Prompt纪律是能实现的。结果聚合也有讲究。三个结果返回后不要直接拼成一个大字符串塞给方案生成Agent。把每个结果作为一个带标签的结构化字段传入比如:await task.callWorker(resolver, { input, orderInfo: orderRes.data, policyInfo: policyRes.data, historyInfo: historyRes.data, });这样resolver的System Prompt里就能明确写“只依据orderInfo字段进行订单判断不要猜测缺失字段。”隔离字段比隔离Agent更细一级效果更好。4.4 工具并发访问与数据一致性的坑在多Agent场景下共享数据的并发写是一个容易踩雷的点。比如质检Agent和方案生成Agent可能同时读写了工单表里的“方案状态”字段。我的经验是不要让Agent直接写业务表所有写操作封装成幂等工具且在Store层做乐观锁。// update-ticket-status.ts const updateTicketStatus defineTool({ name: update_ticket_status, description: 更新工单状态。status可取值: open, processing, resolved, rejected。, parametersSchema: { type: object, properties: { ticketId: { type: string }, status: { type: string, enum: [open, processing, resolved, rejected] }, expectedVersion: { type: number }, }, required: [ticketId, status], }, async execute({ ticketId, status, expectedVersion }, ctx) { const current await ctx.db.getTicket(ticketId); if (current.version ! expectedVersion) { throw new Error(版本冲突: 当前${current.version}, 预期${expectedVersion}); } return ctx.db.updateStatus(ticketId, status); }, });版本号字段在工单表里必须要有。Agent写数据之前先从黑板上拿到版本号写的时候带上。冲突就报错由Director决定重试还是打回。这套做法不新鲜但很多智能体项目压根没做纯粹靠Prompt让Agent“注意并发”——那完全是赌运气。5. 测试、调参与性能优化多Agent项目的三条救生索5.1 确定性测试Mock模型、固定时间种子多Agent项目最闹心的问题是“上次能过这次不能过”。模型有随机性Agent工具调用顺序有波动连并发执行的事件顺序都不可控。我在这类项目上的测试策略分三层第一层是单元测试只测工具函数本身跟Agent无关。第二层是集成测试Mock模型返回固定的JSON输出让Agent执行完整流程断言工具调用序列是否符合预期。第三层是端到端测试接真实模型但只跑冒烟用例不做全量回归。Mock模型这一步要设计一下。我在测试里用了一个简单的MockProvider给每个Worker预设一个队列每次都返回指定内容。const mockModel { async chat(messages, opts) { const queue mockedResponses[opts.workerName] ?? []; const r queue.shift(); if (!r) throw new Error(没有预设的mock响应: ${opts.workerName}); return { content: r.content, toolCalls: r.toolCalls ?? [] }; }, };这个做法的价值是测试失败时你能百分之百确定是业务逻辑的问题而不是模型抽风。等Mock测都通过再跑真实模型对比一轮看哪些用例需要修Prompt。5.2 可视化调试时间线是唯一的真相多Agent链条一长查问题就没法靠console.log了。我强烈建议在框架层做事件时间线记录——每条消息的发送、每个工具调用的起止、每次状态流转都带时间戳塞进Store。OpenClaw默认有这条时间线但很多人没用起来。我自己调试时候的习惯是先看整个Task的时间线列表找哪个Agent的耗时不正常地长或者哪两个Agent在互相发消息形成了循环。看到可疑位置再取单条消息的完整上下文比如这个Agent当时看到的SharedBlackboard是什么。OpenClaw的Bus如果支持订阅事件可以直接写个WebSocket转发到浏览器里做实时面板。效果比看日志高一个维度特别是并行Agent多的时候脑子里根本拼不出全貌。5.3 成本与延迟的取舍把Token花在刀刃上多Agent系统的模型费用是单Agent的好几倍这是物理规律。一个任务跑四个Agent每个Agent收到的消息都包含共享黑板里的长上下文Token可能轻松破万。我从实践里总结出几个降低成本又不伤效果的技巧Agent接收的共享上下文字段要精挑细选不要“全部给”。方案生成Agent不需要看历史工单的原文给它看抽取结果就够了。中间Agent的输出只要结构化字段不要让它啰嗦。比如意图分类Agent我对它的输出格式约束成JSON但允许它输出失败原因失败原因只在失败时进入下一个阶段的上下文。长文档工具调用优先做摘要。查询历史工单列表时让工具返回字段精简版比如只有“时间、类型、结论”摘要由工具层做别把十八行记录全塞给模型。给每个Agent设置独立的MaxTokens别用全局默认值。分类这种小活儿256个Token足够了生成回复的Agent可以高一些。延迟方面主要是刨掉串行化。凡是能并行的工具分组全部Promise.all。另一个有效手段是“提前失败”检查——意图识别如果直接判定为“其他”类就别走后面的抽取和方案生成直接进人工省掉至少三次模型调用。6. 常见问题与排查技巧实录我把自己跑多Agent项目踩过的问题整理成了一个速查表给读者一个直接能用的排查路径。现象常见原因排查手段修复建议两个Agent来回发消息停不下来没有设置循环次数上限查时间线看消息对在Bus层设置最大消息数超限强制失败工具参数偶尔是错的但大部分时间对并行上下文混杂模型被多个任务干扰查看失败样本的工具调用上下文合并工具到同一Prompt或拆成独立的工具调用模型输出的JSON偶尔非法模型本身不稳定尤其小模型在LLM网关层记录原始输出增加JSON Schema校验与重试重试时加入错误信息任务重试后生成了双份结果幂等性没做好查Store里的任务状态所有工具实现天然幂等重试前先确认状态某个Agent的上下文越积越长费用飙升没有做上下文压缩看Session快照大小定期把旧消息折叠为摘要保留最近N条原消息质检Agent总是误判质检规则写在自然语言Prompt里难以稳定抽查误判样本看Prompt得分把硬性合规规则改成代码校验不要让模型背规则循环消息这个问题我多写一句。Agent之间的消息循环在语义上好像“讨论得很热烈”但实际是双方各自拿着之前的输出互相加工最后变成一个螺旋上升的废话生成器。我在框架层加了一个硬编码的防御每对Agent之间的消息链最长10跳超过就触发“需要Director介入”事件由Director决定是终止还是换一种处理路径。这套机制运行到现在没有误伤过正常流程。还有一个细节跟JSON输出有关。很多人让模型输出JSON时喜欢加“只输出JSON不要其他内容”但小模型的指令遵循能力不稳定偶尔还是带出markdown的json围栏。我在工具调用解析逻辑里加了容错先剥离markdown代码块标记再走JSON.parse失败再尝试正则提取大括号内容。这层容错救了我无数次。7. 关于OpenClaw的进一步扩展思路这套结构其实不止客服场景能用。我用同样的架构做过一个内容审核流水线——初审Agent、复核Agent、敏感规则Agent串起来跑质检结果走OpenClaw的状态机管理效果比单Agent稳定得多。还见过有人拿这套做数据分析报告生成数据查询Agent、图表解读Agent、报告写作Agent三个Agent各自持有一份精简过的数据子集最后汇聚成报告。这里有个原则值得强调多Agent的价值不在于“让多个模型角色聊天”而在于把复杂任务拆成多个可以独立验证、独立替换、独立伸缩的小步骤。每个Agent本质上还是那个单Agent但团队的组合能力提升了。想从OpenClaw做二次扩展的话有几个方向可以尝试接入流式输出让用户更早看到中间结果给Store换一个Redis实现让多实例服务可以共享Session状态在Bus上挂一个审计插件所有消息都做敏感信息脱敏。项目如果跑起来流量大了这套结构还能把不同环节的Worker部署成独立的云函数OpenClaw的接口层几乎不需要大改。说到部署多Agent服务和无状态HTTP服务有个明显区别进程重启会丢掉内存里的会话状态所以持久化做得越早越好。我建议不管项目多小第一天就接上Store的持久化实现不要贪图方便只用内存。最后分享一个我个人的真实体会。多Agent开发最花时间的环节从来不是写Agent代码而是做Agent之间的“接口约定”。每个Agent的输入输出字段、错误码、异常行为都要像设计后端API一样严谨。哪个地方偷懒少定义了字段后面排查问题就要多花十倍时间。我把所有Agent的输入输出定义单独放一个types文件夹每次改字段配置都要过Code Review这个习惯帮我躲掉了很多莫名其妙的线上问题。