这两年做LLM应用落地,我最大的感受是:真正难的不是把模型接进对话框,而是把“对话”升级成“协作”。“从 LLM-Chat 到 Agent-Chat”这个标题,说的不是换个前端页面那么轻松的事。早期我们做的LLM-Chat,本质上就是“用户说一句、大模型回一句”的单轮或多轮问答,模型背后要有什么工具、什么知识库,全靠提示词里硬塞。而Agent-Chat则是把原本一个人干活的模型,变成了一个有多工种、有分工、有上下级、有信息传递的团队。这篇文章,我会从一套真实升级项目的视角,把多Agent协作入口的设计思路、核心机制、实操代码和踩坑实录一次性讲清楚。适合正在做LLM应用工程化、想把“问答机器人”往“智能体协作”方向推进的开发者参考。
1. 升级动机:LLM-Chat的边界与Agent-Chat的必然
1.1 LLM-Chat的经典形态与天花板
早期我们线上跑的LLM-Chat系统,架构其实非常简单。一个网关接收用户消息,把消息连同系统提示词一起送给大模型,大模型返回文本,网关把文本回给用户。如果要接外部能力,就靠函数调用或插件机制,由模型决定要不要触发某个工具。这套架构上线快、调试直观,但用一段时间后天花板很明显。
第一个问题是“上下文混装”。所有工具的描述、知识库索引、对话历史、用户意图全塞在提示词里。工具少的时候还能靠模型自己理解,工具一旦超过十个,模型就开始“精神分裂”。要么频繁调用错误的工具,要么在工具描述里迷失主线,把用户问题带偏。我遇到过最典型的案例:用户问“帮我对比两款产品的售后政策”,模型居然去调用了“计算运费”工具,因为在提示词里这个工具的描述和售后政策字段有重叠。
第二个问题是“状态断裂”。LLM-Chat的每一次对话虽然带历史,但本质上是一个无状态的请求-响应循环。一旦中间需要多步操作,比如“先查库存→再算价格→最后生成订单”,每一步都要靠模型自己记住中间结果,而中间结果如果超过上下文窗口就被挤掉了。做过落地的人都懂,这种链路跑十次能成两三次已经是运气。
第三个问题是“能力不可编排”。LLM-Chat的调度是隐式的,模型看到什么工具描述就决定用什么。但真实业务里,很多任务是有固定流程的。比如售后工单:先验证身份、再查订单、判断责任方、生成处理方案。这属于明确的流程编排,靠模型自由发挥,输出的稳定性完全不可控。
1.2 多Agent协作解决的本质问题
Agent-Chat的升级方向,是把这个“单体大脑”拆成“多角色团队”。每个Agent只负责一个明确领域,比如订单Agent只懂订单,库存Agent只懂库存,调度Agent只负责决定把问题交给谁。
这带来的第一个质变是上下文隔离。每个Agent拥有自己独立的系统提示词、工具列表和记忆片段。调度层只需要传递与该Agent相关的上下文,而不是把所有历史一股脑塞给每个模型。上下文长度暴跌,模型精度反而大幅上升。
第二个质变是流程显式化。任务编排不再依赖模型临场发挥,而是通过代码定义好的协作协议完成。调度Agent把任务拆解成子任务,分配给不同Agent,子任务之间的依赖关系由调度层管理。模型负责单点智能,代码负责流程稳定。
第三个质变是可观测性。在LLM-Chat里,你很难回答“这一步模型为什么调用这个工具”。但在Agent-Chat里,每一步的决策者、执行者、输入输出都是独立的单元,日志可以精确到“哪个Agent收到了什么、返回了什么、耗时多少”。线上排障效率完全不在一个量级。
所以,我从一开始就把这次升级定义为“入口升级”:不是换一个大模型,而是把原本单模型的对话入口,改造成一个多Agent协作的调度入口。
2. 入口升级的总体设计思路
2.1 核心设计原则:调度与执行分离
这次升级我最坚持的一点是:调度逻辑和Agent执行逻辑必须完全解耦。调度层不知道Agent内部怎么实现,Agent内部也不知道上下游还有谁。所有协作都通过统一的消息协议完成。
设计上我们分了四层。
接入层负责接收用户请求,做基础预处理,比如会话识别、敏感词过滤、意图初判。接入层不调用任何大模型,它只负责把请求包装成标准消息。
调度层是整个系统的核心。它维护Agent注册表、路由规则、会话状态,负责决定“这个消息应该给谁”。调度层可以调用大模型做路由判断,也可以走规则路由,取决于场景复杂度。
执行层是各个业务Agent。每个Agent独立部署,独立维护自己的提示词和工具。它们从调度层接收任务,执行完把结果返回调度层。Agent之间不允许直接通信,避免协作关系变成一团乱麻。
记忆层负责会话级和任务级的状态存储。会话级状态记录用户与系统的完整交互历史,任务级状态记录当前任务链中每个子任务的执行结果。
2.2 为什么不让Agent直接通信
一开始有同事提议让Agent直接互相调用,比如订单Agent调支付Agent,像微服务一样。这个方案听起来自然,但实际跑起来是灾难。原因很简单:Agent的调用路径不是恒定的,同一个问题今天可能走A链路,明天可能走B链路。如果Agent之间直接通信,那么每次变更都要改多个Agent的代码,而且日志会变成一团乱麻,很难追踪一条请求到底经过了哪些节点。
我们后来采用了“星型拓扑”,所有消息都经过调度层中转。这样做的好处是:第一,链路清晰,只要看调度层的日志就能还原完整调用链;第二,Agent开发完全解耦,新增一个Agent只需要在调度层注册,不需要改动其他Agent;第三,能力复用方便,一个Agent可以被多条链路复用而不自知。
代价是调度层可能成为性能瓶颈。我们的做法是调度层不处理重逻辑,只做路由和消息转发,控制在毫秒级。真正耗时的大模型推理都发生在执行层,所以调度层只要做成无状态服务,水平扩展很容易。
3. 核心机制设计与实操细节
3.1 Agent注册与能力声明
Agent-Chat里的每个Agent都必须在调度层注册,注册信息包含三个核心部分:Agent唯一标识、处理能力描述、接口地址。
Agent唯一标识是稳定ID,比如“order_agent”“inventory_agent”。处理能力描述是给调度模型的“说明书”,它必须写得清晰无歧义。接口地址是调度层调用Agent的HTTP端点。
能力描述怎么写很有讲究。我的经验是:不要写工作职责,要写边界条件。比如订单Agent的描述不能只写“负责订单相关事务”,要写清楚“负责查询订单状态、修改订单备注、生成订单退换货申请;不负责支付操作,支付问题转payment_agent”。边界写清楚,路由准确率会提升很多。
注册信息保存的时候,我们还做了一层能力索引。调度层把每个Agent的描述文本做向量化,存进向量库。当用户请求到来时,调度层可以先做一次向量检索,筛掉明显不相关的Agent,只把候选Agent的描述塞给路由模型。这样即使业务方注册了几十个Agent,路由模型每次只看三五个候选,准确率和推理速度都能兼顾。
3.2 消息路由与意图分发
消息路由是Agent-Chat最关键的环节,也是踩坑最多的环节。我们试过两种方案:规则路由和模型路由。
规则路由适用于意图明确的场景。比如消息里出现订单号且包含“查”字,直接走订单Agent。这种方案快、便宜、可解释,但覆盖不了复杂表达。用户说“我上周买的东西到现在没发货,帮我看看”,里面既没订单号也没有明确的“查”字,规则就失效了。
模型路由用的是调度模型加少数候选Agent描述做意图分类。这种方式灵活,但有个隐藏风险:调度模型的输出不是百分百稳定。我们遇到过模型把“退货退款”路由到“售后投诉”Agent的情况,因为两个Agent的描述都提到了“退款”。
最终我们采用了混合路由:先规则后模型。规则命中就直接走规则结果,同时记录置信度;规则没命中才走模型路由。模型路由的结果也不是直接执行,而是要经过一层校验,检查目标Agent的边界描述是否与用户意图匹配。不匹配就拒绝并二次路由。
另外强烈建议在路由消息时带上“路由理由”。调度模型输出目标Agent ID的同时,输出一句自然语言的理由。理由是给开发人员排障用的,也是给用户兜底用的。有一次路由错了,用户反馈后我们看理由,发现模型理解成了“用户想对比产品”,但用户实际是想“开售后单”。理由字段帮我们快速定位了Agent描述里缺失的边界条件。
3.3 上下文管理与记忆传递
多Agent协作里最容易翻车的不是单个Agent的对话能力,而是上下文怎么在多Agent之间传递。我们设计了三层记忆结构。
第一层是用户会话记忆,存的是用户和系统的总对话历史。这一层记忆属于用户,不属于任何Agent。无论消息经过哪个Agent,最终都要回到调度层,调度层把当前消息和Agent的返回结果追加到会话历史里。
第二层是任务上下文,存的是当前任务链的执行状态。比如这次任务是“查订单并计算赔偿金额”,那么订单Agent返回的订单信息会暂存在任务上下文里,后续计算赔偿的Agent可以直接读取,不需要用户重新提供。
第三层是Agent私有记忆,存的是Agent自己沉淀的知识和历史执行经验。这个不会传回调度层,避免信息爆炸。
实操中要给每一层记忆设置生命周期。用户会话记忆跟着会话走,30天无活跃就清理;任务上下文跟着任务走,任务结束就销毁;Agent私有记忆由Agent自己管理,可以长期保存。我见过线上事故就是因为任务上下文没销毁,上一个用户的理赔金额被下一个用户引用了。这种问题一旦发生,后果很严重。
3.4 协作协议与任务编排
多Agent协作不能靠调度层“一个问题一个Agent”的简单分发解决,真实任务往往是多个Agent接力完成。
我们定义了一套轻量级的任务编排协议。协议核心是一个“任务对象”,包含任务ID、目标描述、状态、子任务列表、上下文挂载点。调度层根据用户意图和已有流程定义,决定当前任务的子任务序列。
举个例子:用户发起“退货退款申请”。调度层识别出需要三个子任务:第一,查订单状态;第二,校验退货退款资格;第三,生成退款单。这三个子任务有先后依赖,调度层会在订单Agent返回成功后,把订单数据写入任务上下文,再带着任务上下文调用资格校验Agent,最后调用提单Agent。
编排不仅是串行,也支持并行和条件分支。比如同时查库存和查价格可以并行;如果资格校验不通过,直接终止任务并生成失败原因,不需要走到提单步骤。这些控制逻辑全部在调度层代码里写死,不让模型自由发挥。
我建议编排逻辑单独建一个流程定义文件,用JSON或YAML描述,不要硬编码在代码里。这样产品同学可以调整流程顺序,开发不用跟着改代码。流程定义文件本身就是一份可读的文档,上线前评审也方便。
4. 关键代码实现与落地细节
4.1 标准消息体与路由结构
所有Agent之间传递的消息必须遵循统一的协议结构。我们用的是一套轻量JSON协议,核心字段如下:
{ "message_id": "d3f2c1a9-7b0e-4a2f-9c56-8b4e2f0a1c7d", "session_id": "user_00x_20250101", "task_id": "task_8890123", "sender": "scheduler", "receiver": "order_agent", "message_type": "task_request", "priority": "normal", "payload": { "action": "query_order", "params": { "order_id": "A10086", "customer_id": "cust_001" } }, "context_ref": { "task_context_id": "ctx_7788", "include_keys": ["order_snapshot"] }, "metadata": { "timestamp": "2025-01-01T10:30:00Z", "trace_id": "trace_ab12cd" } }这里有几个设计细节要特别注意。
context_ref是用来“拉取”而不是“推送”上下文的。调度层不会把完整上下文随消息发给Agent,而是给一个上下文ID和需要的键名。Agent根据ID去记忆层按需读取。这样做的好处是减少Agent的无效数据接收量,在大上下文场景下效果尤其明显。
message_type区分了任务请求、任务响应、状态查询、心跳等类型。调度层根据类型走不同的处理逻辑,而不是所有消息一个流程。一开始我们只用了task_request和task_response两种,后来线上排查问题发现信息不够,又加了status_report和error_report,让Agent主动上报执行状态。
trace_id是贯穿整个任务链的追踪ID。所有Agent在日志里都必须输出这个ID,这样调度层、执行层、记忆层的日志可以串联。没有trace_id的时候,排查一次跨Agent问题要翻六七个系统日志,有了之后一条命令全查出来。
4.2 调度模型的路由与校验
调度层调用模型的逻辑我用Python实现了一版,核心思路是“候选过滤 + 决策 + 校验”。
class AgentRouter: def __init__(self, agent_registry, vector_index): self.agent_registry = agent_registry self.vector_index = vector_index def route(self, user_message, user_intent): # 候选过滤:先语义检索缩小范围 candidates = self.vector_index.search(user_message, top_k=5) candidate_metas = [ self.agent_registry.get_agent_description(agent_id) for agent_id in candidates ] # 决策:让模型从候选里挑一个最合适的目标 prompt = self.build_routing_prompt( user_message=user_message, user_intent=user_intent, candidates=candidate_metas ) decision = self.routing_model.run(prompt) # 校验:决策结果必须通过边界检查 target_agent = decision["target_agent"] boundary_ok = self.boundary_check( user_message=user_message, agent_meta=self.agent_registry.get_agent_description(target_agent) ) if not boundary_ok: return self.fallback_route(user_message) return target_agentboundary_check的实现不复杂,核心是判断Agent描述里的边界条件与用户消息是否冲突。比如Agent描述明确写了“不负责支付操作”,但用户消息里出现了“扣款失败”,就要拒绝这次路由。
候选过滤这一步当初没有直接采用,因为早期版本是把所有Agent的描述都塞给模型,结果模型面对几十个描述时选择困难。改成候选过滤后,路由准确率从78%提升到了91%,最关键的是推理延迟降了将近一半。
我还顺手做了一个路由置信度统计。调度模型返回一个0到1的置信度值,低于0.6的请求自动转入人工兜底队列,不让用户面对大概率错误的结果。线上跑了一段后,用户投诉率明显下降。
4.3 Agent执行器的封装模式
执行层的Agent我们统一封装了一套执行器,开发者只需要关注两件事:处理什么任务、返回什么结果。
class BaseAgent: def __init__(self, agent_id, llm_client, tools): self.agent_id = agent_id self.llm_client = llm_client self.tools = tools async def handle_task(self, message): # 1. 从消息里解析任务参数 params = message["payload"]["params"] # 2. 从记忆层拉取需要的上下文 context = await self.pull_context(message.get("context_ref")) # 3. 执行核心逻辑:模型 + 工具协作 result = await self.run_core(params, context) # 4. 把结果写回任务上下文 await self.push_result(message["task_id"], result) # 5. 返回标准响应 return self.build_response(message, result) async def run_core(self, params, context): # 子类实现 raise NotImplementedErrorAgent的核心逻辑我们建议做成“工具优先、模型兜底”。能用确定性工具解决的,比如查数据库、调接口,就不走大模型。只有需要理解和生成文本的地方才调用模型。这样做的直接收益是省钱和提速。一个查库存的动作走SQL是几十毫秒,走模型函数调用是几秒钟,价格差上百倍。
我还要求所有Agent的输出必须是结构化JSON,不允许输出纯自然语言。即便最终要给用户看的回复,也要在JSON里单独放一个user_facing_message字段。这样调度层可以把Agent的内部结果和展示文本分开处理,方便后续做多轮追问和结果复用。
4.4 会话状态机的设计
Agent-Chat入口的会话状态,比LLM-Chat复杂得多。LLM-Chat的会话状态就一个message数组,Agent-Chat需要维护会话里的“当前活动任务”状态。
我们用了一张表存会话状态,核心字段如下:
| 字段 | 说明 |
|---|---|
| session_id | 用户会话ID |
| current_task_id | 当前正在执行的任务ID,空表示无活动任务 |
| task_status | 任务状态:pending / running / waiting / finished / failed / cancelled |
| waiting_reason | 当前阻塞原因,比如“等待用户补充地址信息” |
| active_agents | 当前参与任务链的Agent列表 |
| task_context_ids | 该会话关联的任务上下文ID列表 |
这张状态表是调度层判断“继续执行旧任务”还是“开启新任务”的依据。
一个常见场景是用户在任务执行过程中追加新消息。比如用户先问“我的订单到哪了”,调度层开启了查订单任务,任务执行到一半用户又说“顺便帮我把收货地址改了”。此时调度层不能当作完全独立的新任务处理,而是要把这条追加消息作为当前任务的补充输入。我们通过状态表判断,如果当前任务处于waiting状态且新消息能解除阻塞,则直接把新消息写入任务上下文,让当前任务继续执行。
这个设计在实操中帮我避免了一个大坑。最初版本是每条用户消息都独立路由,导致用户说“好的”的时候,调度层完全不知道“好的”指的是什么,只能开一个无头无尾的闲谈任务。有了会话状态机后,“好的”会被识别为对当前任务的确认指令,直接推进任务流转。
5. 常见问题与排查经验
5.1 路由不准:加规则还是换提示词
线上最容易遇到的现象是:同一个问题,上午路由对了,下午路由错了。排查这类问题我的顺序是:先查规则命中日志,再看模型路由置信度,最后看Agent描述是否有改动。
经验是:路由不准大部分时候不是模型智商问题,而是候选Agent描述出现了语义重叠。比如两个Agent都包含“退款”关键词,模型就会随机派发。解决办法不是疯狂调提示词,而是把语义重叠部分剥掉,明确划分边界。有一次我们只改了订单Agent描述里的一句话,把“处理退款”改成“处理订单退款;支付环节的资金原路退回由支付Agent处理”,路由准确率立刻回升。
如果改了描述还不稳,再考虑加硬规则覆盖高频场景。硬规则命中后不走模型,同时不影响低频场景的灵活性。
5.2 上下文串扰与脏数据
多Agent上下文传递的最严重问题,就是A任务的结果被B任务引用。我们踩过一次真实事故:任务上下文的key命名太粗糙,两个不相关的任务用了同一个key,结果库存Agent读取到了订单Agent写入的数据。
这个问题的根本解决办法是在上下文key上增加任务ID前缀。读取上下文的时候强行校验所属任务,不允许跨任务读取。写入的时候也要带上写任务ID,调度层在任务结束时统一校验上下文里的数据来源,发现来源不一致直接隔离。
另外建议上下文里的敏感字段单独加密存储。比如用户手机号、地址这类字段,不应该在任务上下文里明文流转。我们后来把所有上下文数据的存储改为加密,读取时按需解密,日志里只输出脱敏后的值。
5.3 Agent循环调用与任务卡死
多Agent协作最怕的是A调B,B又调A,形成死循环。虽然我们设计了星型拓扑,但逻辑层还是可能出现循环的。比如订单Agent在生成退款方案时,发现需要发票信息,于是请求发票Agent;发票Agent验证发票后发现订单状态异常,又请求订单Agent更新状态;订单Agent更新状态后又触发退款方案重新生成……这个循环直到上下文爆掉才被截断。
我们在调度层加了深度限制:单个任务链的子任务数量不能超过20,超过直接终止并触发人工审查。同时为每个子任务设置超时时间,超时就标记失败并执行兜底逻辑。兜底逻辑不重新调Agent,而是直接给用户回复“当前处理遇到问题,已记录并转人工跟进”,保证用户体验不卡死。
这些限制看起来简单粗暴,但确实管用。上线到现在半年,没有发生过一次无限循环的事故。
5.4 入口响应变慢与并发控制
Agent-Chat的链路比LLM-Chat长,单次请求可能经历调度模型、多个Agent的多次模型调用,整体延迟翻了几倍。用户打字半天没反应,体验很差。
我的优化思路是“能并行就并行、能跳步就跳步”。并行执行上面说了,调度的子任务里没有依赖的全塞进线程池。跳步是指:如果某个环节的输出对当前任务结果没有影响,就先不调用,等用户追问时再补齐。比如用户只是问“能不能退货”,就不需要先生成完整的退款单,先查资格就够了,等用户确认退货再走后续流程。
给用户侧的反馈也要做分层:先秒回“收到,正在处理”,让用户感知到系统响应了,再把最终结果异步返回。这个前端交互的升级,对体验的提升比后端优化还直观。
6. 个人实操体会与后续扩展
从LLM-Chat升级到Agent-Chat,我最大的体会是:问题复杂度不是线性增加,而是指数增加。单模型对话只需要考虑“模型答得好不好”,多Agent协作要面对的是“每个Agent答得好不好、路由对不对、上下文乱不乱、任务链能不能稳定跑完”。但熬过这轮升级,系统的能力边界完全不一样了。以前遇到复杂任务只能回答“我暂时无法处理”,现在可以直接拆解并执行,这是量变到质变的区别。
如果让我重新来一次,我会在项目启动第一天就把可观测性做起来。trace_id、结构化日志、状态表这些不是后期补的优化项,而是从一开始就要有的基础设施。中途再补观测的代价,比想象中高一倍都不止。
后续这个入口还可以往三个方向扩展:一是把人工客服无缝接入Agent协作链路,让用户在Agent处理不了的时候一键转人工,工单上下文自动同步,客服不用让用户复述第二遍;二是把Agent的决策过程沉淀成可召回的历史案例,出现类似问题时优先匹配成功经验,减少模型的重复推理;三是把Agent调度能力从“对话入口”抽象成“任务入口”,也就是不仅用户通过聊天触发Agent,业务系统的表单、定时任务、外部事件也能触发同样的协作流程。到那时候,这个多Agent协作入口就不再只是聊天机器人的升级版,而是整个业务系统的智能任务中枢了。