☰
多Agent架构设计与OpenClaw编排:从消息协议到状态机实践
2026/10/13 8:16:18 网站建设 项目流程

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: Record<string, 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 ToolDefinition<Args = unknown> { name: string; description: string; parametersSchema: Record<string, unknown>; execute: (args: Args, context: ToolContext) => Promise<unknown>; } 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: Record<WorkflowState, 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,这个习惯帮我躲掉了很多莫名其妙的线上问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询