ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从单模型Chat到多Agent协作:入口全链路升级实践

从单模型Chat到多Agent协作:入口全链路升级实践

去年我们团队把一个单模型的聊天服务升级成多Agent协作的消息总线,整个过程比预想中痛苦得多。起初以为只是把聊天窗口改成能“拉群”的样子,把路由层加几个分支,把模型调用从一次变成N次,结果发现:LLM-Chat和Agent-Chat根本不是同一个物种。一个是单线程的问答通道,一个是多角色并行合作的工作台。入口一旦升级,会话模型、消息协议、前端状态同步、服务端编排、线上可观测性,全链路都得跟着动。

这篇文章把这次升级的核心设计过一遍——为什么要改、改哪里、怎么落地、以及那些不踩一遍根本意识不到的坑。主要面向正在从单模型对话入口往多Agent协作入口演进、但还没想清楚“入口究竟长什么样”的团队。如果你只是想在现有Chat上做个路由分流,这篇文章能帮你少走一半弯路。

1. 为什么单模型Chat的“舒适区”必须被打破

1.1 单模型聊天的三个隐性边界

先回到底层问题:一个普通的LLM-Chat入口,本质上是“用户 ↔ 一个模型实例”的问答闭环。它能够工作,是因为所有上下文都被压进同一个上下文窗口,模型自己承担理解、检索、规划、生成的整个链条。

这种形态有三个边界。

第一是职责边界。一个模型同时做意图识别、知识检索、内容生成和工具调用,就像一个人又当前台又当项目经理又当执行者。当任务稍复杂,指令稍微含糊,模型就要靠“猜”来补位,于是出现答非所问、信息混用。某个金融行业客户当时遇到的问题很典型:让同一个模型既回答产品问题又执行单据查询,它总会在回答产品细节时错误地推断出“该用户已欠费”之类结论,因为它把两种任务的上下文混在一起了。

第二是状态边界。单模型Chat的会话状态基本就是“历史消息数组”。你在这个数组里塞任何结构化信息——任务状态、工具结果、Agent身份、分支上下文——都会变成上下文碎片,既占token又干扰生成。

第三是流程边界。真正的协作不是“一次生成”,而是一连串动作:A分析、B查数据、C做方案、D审核,中间来回反馈。单模型Chat天然是单轮的,没有角色切换、没有任务流转、没有并行分支。硬塞进一个会话里,用户只看到一团黑箱。

1.2 多Agent协作对入口提出的三个升级诉求

当我们决定把系统改成多Agent协作形态,最先被逼着改的不是模型,而是入口。入口是用户和Agent群之间的“前台”,它必须满足三个诉求。

诉求一:可见性。用户要知道现在是谁在处理任务、谁调用过工具、谁在等待谁的结果。原来那个“正在输入…”的菊花状态根本不够用。诉求二:可控性。多Agent系统里必须有“叫停、重试、指定角色继续处理”的入口级控制。不能一发起任务就变成脱缰野马。诉求三:可追溯性。一个任务经过多个Agent协作之后,用户要能回放整个过程,出了问题要能定位到某个Agent的某一次输出。做不到这一点,多Agent系统在生产环境基本是灾难。

所以Agent-Chat不是给聊天框加几个功能,而是把“聊天”重新定义成“人在回路上实时参与的协作控制台”。这也是整个升级设计的出发点。

2. 先重设计数据模型,再谈界面和路由

2.1 会话从“一维消息流”变成“二维协作空间”

单模型Chat的会话模型通常长这样:session包含messages,messages是一条条带role的文本。升级后的第一件事就是打破这个一维模型。

我们最终把会话拆成三个层次:会话(Session)、任务(Task)和消息(Message)。

会话是顶层容器,代表用户与Agent群的一次完整协作过程。会话下可以挂多个任务,任务对应一个明确目标(例如“生成季度销售报告”);任务内部再展开具体的消息交换和Agent执行记录。这样设计的原因是:多Agent协作天然是树状结构,一个主任务会拆出子任务,子任务会由不同Agent处理,处理过程又有中间消息。如果还是一条平铺消息数组,前端展示和后端定位都会崩。

数据模型大致是这个形态:

@dataclass class Task: task_id: str session_id: str parent_task_id: str | None goal: str status: str # pending / running / waiting / done / failed / cancelled assignees: list[str] # agent ids created_at: datetime finished_at: datetime | None @dataclass class Message: msg_id: str session_id: str task_id: str | None agent_id: str | None role: str # user / agent / system / tool content: str references: list[str] # 引用消息ID、工具调用ID

这个模型的关键点在于:task_id把消息从“流水账”变成“可分组的工作记录”,agent_id让每条消息有了责任主体,references让消息之间的依赖关系显式化,而不是靠人去上下文里猜。

2.2 消息协议从 role 两元组走向结构化

原来Chat接口传消息,基本就是“role + content”,最多再加个metadata。多Agent场景下这个结构远远不够。

我们参考实践后把消息协议扩展成如下结构。这里以JSON示例展示:

{ "message_id": "msg_8f3a2b", "session_id": "sess_1001", "task_id": "task_52", "agent_id": "fin_agent_v3", "role": "agent", "content_type": "analysis_result", "payload": { "summary": "供应商账期平均45天", "risk_level": "medium", "suggestions": ["缩短付款周期", "对两家长账期供应商重新谈判"] }, "meta": { "model": "claude-sonnet-4", "temperature": 0.2, "tools_used": ["sql_search", "pdf_parser"], "latency_ms": 1240 }, "references": ["tool_call_a1", "task_51"] }

content_type和payload的引入很关键。因为Agent输出的不只是文字,可能是表格、卡片、图表数据、工具结果。如果用纯文本硬扛,前端要解析一万种文本格式,后段要处理一万种奇怪截断。相反,给协议一层“结构化载荷”,展示层就能按类型渲染。

我们最终定了一套Agent-Chat内容类型规范,常见的包括:text、analysis_result、tool_result、plan_preview、question_to_user、approval_request、subtask_progress。每种类型对应不同的渲染模板和交互控件。这一步看着笨重,实际救了后期前端和后端联调的大量时间。

2.3 上下文边界“显式化”是入口升级的分水岭

聊过头你就会发现,多Agent场景最大的敌人是“上下文串味”。A Agent看到的对话历史、B Agent能访问的消息、C Agent最终生成时参考的材料,如果都从同一个“会话全局上下文”里取,协作过程很快就会互相污染。

所以入口层的上下文管理必须从“整包传递”改成“按需切片”。我们的做法是给每个Agent配置一个上下文窗口,从三个来源组装内容:

当前任务的目标和约束、分配给该Agent的输入消息、允许调用的共享记忆块。

这个逻辑用一个简单的上下文组装函数表达:

def build_agent_context(agent_cfg, task, session_repo): slices = [] # 1. 系统指令:agent身份 + 权限边界 slices.append(agent_cfg.system_prompt) # 2. 任务上下文:只取当前任务及其直接祖先任务 slices.append(collect_task_chain(task)) # 3. 消息上下文:只取该Agent参与或指定可见的消息 visible_msgs = session_repo.get_visible_messages( agent=agent_cfg.agent_id, task=task.task_id ) slices.append(render_message_slices(visible_msgs)) # 4. 共享记忆块:按需拉取,而不是全量注入 mem_blocks = memory_service.pull( namespace=f"{task.task_id}", tags=agent_cfg.memory_tags ) return join_slices(slices, mem_blocks)

这个设计的核心转变:把“要不要看这条上下文”从model的隐式行为变成Agent配置的显式规则。虽然增加了配置成本,但换来的是跨Agent协作时干净的信息边界。上线后很多“Agent之间一本糊涂账”的问题,都根源在前期没做这一步。

3. 前端聊天入口如何“向上兼容”Agent协作

3.1 消息渲染的四种形态决定了前端骨架

前端是用户对Agent-Chat最直观的感知。多Agent聊天里,消息不再只有“左边用户、右边AI”两种形态,至少要支持四类:

  • 用户消息:依旧在右侧,但允许用户指定目标Agent或任务,比如“让财务Agent分析账期,并让合规Agent复核”。
  • Agent工作消息:展示某个Agent处理过程中的中间状态、工具调用记录、子任务分发情况。这类消息默认折叠,展开后能看到过程详情。
  • Agent结论消息:某个Agent产出最终结论时推送,一般带结构化载荷,比如数据表格、审批卡片。
  • 系统编排消息:例如“任务已从Agent-A转交给Agent-B”“等待用户确认后继续”这类控制消息。

前端最初的实现方案是“一个组件搞定所有消息渲染”,结果代码里堆满了if-else。后来改成“类型注册 + 渲染器映射”的模式,每个content_type对应一个React组件,渲染器按字段查找组件类型。加新Agent能力时,前端只加一个组件,而不是在巨型组件里继续堆分支。

3.2 会话侧边栏的“任务树”比消息列表更实用

多Agent协作里,用户盯着的是“一个任务被推进到哪一步了”,而不是“今天一共发了几条消息”。所以我们在入口左侧做了一棵任务树,层级关系是:

会话 → 主任务 → 子任务 → 执行记录

任务树的状态直接用后端的Task状态机驱动。某个Agent卡住了,侧边栏对应子任务标题后面显示等待标记;用户想要求重新处理,点击子任务右键,就能单独重试该子任务。这个交互形态比原来“把整段对话喂给模型再生成一次”要精细得多,也符合真实团队协作直觉。

这里有个容易被忽视的细节:任务树和消息流必须双向联动。用户点侧边栏的某个子任务,下方消息流自动滚动到与该子任务相关的消息区间,并且高亮Agent标识。我们的实现方式很简单——每条消息渲染时给DOM元素挂一个>REGISTERED_AGENTS = [ { "agent_id": "fin_agent_v3", "capabilities": [ "query_vendor_payment", "generate_invoice_report", "analyze_accounting_period" ], "permission_tags": ["finance_internal"], "input_constraints": {"max_batch_size": 100} }, { "agent_id": "compliance_agent_v2", "capabilities": [ "regulatory_check", "approval_flow_audit" ], "permission_tags": ["compliance"] } ]

选举时除了看能力匹配度,还要看Agent当前负载、是否被用户指定、以及权限上下文。用户如果显式说“让合规Agent复核”,路由就直接跳过选举,指定目标Agent。这里有个很重要的顺序原则:用户指定优先于算法选举,因为在协作场景里,人的明确指令就是最高优先级。

4.2 Agent生命周期与会话状态机的绑定

多Agent协作下,最怕的是Agent任务跑了一半,会话被用户关闭,或者Agent-A已经产出结果但Agent-B还在等输入,整个系统就卡死了。所以我们把Agent生命周期和会话状态机做了绑定。

每个Agent实例有独立生命周期:idle → reserved → running → waiting → done/failed/cancelled。会话层面的状态机则维护整个任务群的大盘:active → paused → completing → closed。

关键设计是“状态转移必须经过编排服务”。所有状态变化都产生事件,事件写入事件流,前端和审计系统都从这个事件流读取状态。状态机的实现参考了最朴素的消息驱动模型,但有一个我们坚持的细节:所有转移都必须带context字段,说明为什么转移,比如“fin_agent_v3 因缺少供应商数据进入 waiting,等待 data_agent_v2 返回”。这个字段让后续排查问题省了无数口水。

4.3 记忆隔离与共享:从“一锅乱炖”到“分级访问”

这是多Agent入口升级中代价最大的一块。早期我们把所有Agent的历史消息都扔进同一个记忆库,Agent-A在解题时把Agent-B的旧结论当成了事实依据,闹出过不少笑话。后来参照权限系统的思路做了一套记忆分级访问。

记忆按三个维度打标签:命名空间(会话/任务)、话题标签(如“账期分析”“合规规则”)、可见性级别(公开、团队、私有)。每个Agent有一个memory_access_policy,只有同时满足命名空间和可见性要求,才能拉取对应记忆块。例如审计Agent默认只能读自己任务域内的记忆,不能读财务Agent的私有工作草稿,但如果任务显式挂了一个“审计授权”标签,就可以拉取指定范围的财务信息。

这个设计的代价是Agent在协作时不能天然共享全部上下文,需要显式地传递消息和引用。但也正是这种“不自然”,逼着我们把协作流程显式化为任务分派和结果传递,而不是靠模型在长上下文里“悟”。对生产系统来说,显式永远比隐式可靠。

4.4 Agent间通信:需要一套轻量的消息协议

最初我们让Agent之间直接调用彼此的“函数”,结果耦合严重——改一个Agent的签名,其他三个Agent都崩。后来我们把Agent间通信统一成消息队列,所有协作动作都变成“发消息”而非“调函数”。

消息类型包括:task_assign(下发子任务)、task_result(返回处理结果)、request_clarification(请求补充信息)、request_approval(请求审批)。每个消息都带correlation_id关联原始任务,方便追踪。

这套消息机制让Agent从“紧耦合函数调用”变成了“松耦合消息协作”,代价是需要做协议设计,但换来的好处非常明显:可以单独重跑某个Consumer Agent,可以给某类消息做限流,可以在UI上把Agent间通信完整呈现出来。我的建议是,不要试图让Agent之间像函数调用那样“无缝”,反而要让消息边界更显眼,这样出问题才能切得开。

5. 事件流与回放:Agent-Chat能不能上线,就看这一层

5.1 事件溯源:让会话“每一帧都可重放”

多Agent协作的调试太特殊了。它不是“用户问一句,模型答一句”的简单链路,而是N个Agent并发推进、互相喂数据的复杂网络。如果没有可靠的事件记录,出了问题你根本不知道是哪个Agent传递了错误数据。

我们最终给会话接了事件溯源。所有操作——用户输入、Agent产出、工具调用、状态转移、审批动作——都追加进一个不可变事件流。事件结构哪怕复杂一点也值得:

{ "event_id": "evt_7001", "timestamp": "2025-06-11T10:23:15.220Z", "session_id": "sess_1001", "event_type": "agent.task_completed", "payload": { "task_id": "task_52", "agent_id": "fin_agent_v3", "summary": "完成账期分析,结果见 msg_8f3a2b", "next": ["task_53"] }, "prev_events": ["evt_6998", "evt_6999"] }

事件流有两个用途。一是回放,前端可以把整个协作过程像视频一样从头演一遍,用户可以逐帧查看每一条Agent输出。二是排查,后端定位问题时,可以从事件流里找出某条消息的完整因果链,看它到底引用了哪些前置事件。

5.2 前端回放器的实现思路

前端的回放器并不复杂,但有个关键点:回放的不是“消息列表”,而是“状态机历史”。也就是把事件流逐个喂给前端的状态合并逻辑,像重演动画一样重建任务树和消息流。

我们的简化实现是:

async function replaySession(sessionId, eventStore) { const state = createEmptySessionState(); const events = await eventStore.load(sessionId); for (const evt of events) { applyEvent(state, evt); // 与实时版共用同一套 apply 函数 render(state); // 每一帧渲染 await sleep(evt.durationMs || 100); } return state; }

复用同一套applyEvent是这里最重要的设计。线上实时接收事件时用它更新状态,回放时也用它重建状态,两边逻辑完全一致,否则可能“回放出来的过程和线上发生的不一样”。

5.3 用事件流支撑“语义级调试”

事件流真正的价值不是UI回放,而是语义级调试。有一次线上用户反馈“财务Agent给出一份报告,但引用数据是旧版本的”。我们排查路径很简单:根据用户报告中references字段找到Agent生成时引用的消息ID,再从事件流里找到这条消息是哪次工具调用的产物,顺着工具调用事件追到数据源版本号——整个过程没有翻聊天日志,没有猜。

这让我坚定了一个看法:只要走多Agent路线,事件流就是基建的一部分。你可以先不上前端回放器,但一定要把事件流存储好,这是后面一切问题排查和体验优化的地基。

6. 实战中踩过的四个坑与解决方案

6.1 坑一:Agent自我对话把聊天历史污染得一塌糊涂

第一版Agent-Chat上线后,用户反馈“对话历史里到处都是看不懂的消息”。一查,原来是Agent-A和Agent-B之间传数据的中间消息被塞进了用户可见消息流,用户看到一堆“F0442号任务已完成,输出JSON…”“已收到,正在计算…”之类的机器人对话。

解决方案:消息分两层。agent-internal消息只进事件流存证,不进用户可见消息流;只有最终面向用户的Agent结论、明确需要用户确认的中间结果,才渲染到聊天界面。这个切分看起来简单,但需要在协议级别定义visibility字段,同时前端对消息列表做过滤。经历过这次,我更确信“Agent之间怎么说”和“Agent对用户说什么”必须完全隔离。

6.2 坑二:轮询策略把上下文窗口撑爆了

多Agent协作时,任务多、中间结果多,每个Agent在生成最终答案时都可能拉取全量协作历史。当时一个Agent生成报告时把整个会话的几百条消息全塞进了上下文,token直接爆掉,而且生成的报告泛泛而谈毫无重点。

解决思路是“上下文窗口按任务裁剪 + 显式引用优先”。最终答案生成器只能访问当前任务相关的消息,而当Agent需要跨任务引用某个结论时,必须通过references字段显式引用,系统只把被引用的消息加载进窗口。这样上下文规模从“会话长度”降级为“任务依赖图的边长”。上线后token费用降了大概四成,答案质量反而提升了,因为上下文噪音变小了。

6.3 坑三:实时状态推送和用户输入抢竞态

前端有一个竞态问题:用户在输入框打字时,Agent的状态推送不断刷新消息区,导致页面跳动、输入框跟着重排。起初以为是性能问题,后来发现是状态更新没有和用户焦点做协调。

解决方式:前端引入“聚焦模式”。如果用户正在输入框内连续输入,实时状态更新只更新侧边栏的任务树和返回顶部的小角标,不自动滚动消息区;用户点击“查看最新”或按下回车,才主动滚动并展开增量消息。这个交互细节很小,但直接影响入口的“可信度”——用户牢牢掌握观看权,而不是被机器推着走。

6.4 坑四:Agent全是“黑箱”,用户下意识不信任

上线前我们觉得多Agent协作最酷的是“自动完成”,上线后发现用户最关心的是“谁干的”“怎么干的”。当用户看到某个Agent突然做了一项操作,总是怀疑它是不是理解错了,哪怕结果正确也不放心。

后来我们在每条Agent结论消息上都挂了“展开执行过程”的链接:点击后展示该Agent收到的任务输入、调用的工具和关键的依据片段。这一步切中了核心痛点——多Agent入口给用户的安全感,不在“自动化程度”,而在“可审查程度”。现在这个设计已经成为我们Agent-Chat的标准功能。

7. 几句实在话,给准备做同方向升级的团队

如果让我把这次升级总结成一句话:LLM-Chat升级到Agent-Chat,真正的投入不在模型,而在入口层的工程化——事件流设计、上下文裁剪、消息协议、状态同步、权限与记忆边界。模型能力再强,入口一团乱麻,用户也不会觉得这是“协作”,只会觉得这是一个擅长自言自语的黑盒子。

有两个小建议值得参考。第一,项目早期宁可少放几个Agent,也要先把会话/任务/消息三层模型和事件流跑通,因为后期所有功能都长在这两个骨架上。第二,路由层的“用户指定优先”原则一定要前置;把选择权留给用户,后面能少背很多锅。这个方向后续还可以往两个地方扩展:一是把Agent的输入输出沉淀为可复用的团队知识库,二是给前端增加更精细的数据流可视化,让用户像看“协同驾驶舱”一样看整个协作过程。路还长,先把入口这层地基打稳,后面扩展就顺了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进