ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

多Agent架构实战:用Agent小队重构智能旅行助手

多Agent架构实战:用Agent小队重构智能旅行助手

我去年年底接手了一个很有意思的项目:给一家做行程规划工具的团队重构他们的核心能力,把原来一个单体大Prompt驱动的问答机器人,改造成一个基于multi-agent架构的智能旅行助手。整个过程从架构设计、角色拆分,到踩坑调试、效果评测,前后折腾了将近两个月。这篇博文就是把这段实战经历完整复盘一遍,从需求拆解到系统落地,再到那些文档里不会写的坑,一次性讲清楚。不管你是被多Agent概念吸引想动手试试,还是已经在做类似场景但被各种奇葩bug折磨,这篇应该都能给你一些参考。

1. 为什么传统旅行App解决不了“真问题”

1.1 你搜索到的不是答案,而是一堆待处理的碎片

先从一个很朴素的问题说起:我们真的缺旅行信息吗?完全不缺。打开任何一款主流旅行应用,你能查到航班、酒店、景点评分、天气预报、餐厅推荐,信息量巨大。但问题是,这些信息全部是“碎片”,需要用户自己去拼装。查航班要去机票模块,查酒店要去酒店模块,查景点要切到社区笔记,查天气还得跳出App打开另一个工具。一趟行程涉及到十几个约束条件,用户实际上在脑子里跑着一个非常复杂的整合程序。

我在接手这个项目前做了不少用户访谈。印象最深的一位用户说,她过去三年每次家庭出游都要花四五个晚上做行程,不是因为不知道去哪儿,而是因为“要把机票、酒店、孩子午睡时间、老人走路速度全都揉在一起,每次都会漏掉一两项”。这句话基本戳中了传统旅行工具的命门:它们擅长提供选项,不擅长做决策;擅长展示信息,不擅长处理约束。而多Agent架构天然适合解决的问题,恰恰就是这种多约束、多来源、多子任务的整合型任务。

1.2 旅行计划本质上是一个多约束优化问题

如果把一次旅行计划当成一个工程问题来看,它其实是一个典型的多约束优化:预算上限、日期区间、人员构成、体力阈值、兴趣偏好、天气条件、交通衔接、用餐需求。这些约束互相耦合,比如选了某个航班,就会影响落地时间和第一天行程;酒店位置选得偏远,每天的交通成本就会上升;景点安排在户外,就得看天气预报来定顺序。

传统的单体大模型Prompt方案在处理这个问题时有个结构性缺陷:所有约束都被塞进同一个上下文里,模型需要在一次推理中同时记住预算、天气、家庭状况、交通时刻表等大量事实,还要做多步规划。一旦上下文变长,模型很容易“忘掉”早期约束,或者为了输出一个看似完整的行程而做出局部最优选择。我此前用一个超大上下文模型做单体Prompt原型,实测到了第三天行程,经常出现预算超支或者把两小时车程的景点排进同一上午的情况。这不是模型不行,而是任务结构本身就该拆开处理。

1.3 为什么多Agent架构天然适配旅行规划场景

多Agent系统的核心思路,不是“一个超级智能体处理所有事情”,而是“让多个专注的智能体分别处理自己最擅长的子任务,再通过协作机制整合结果”。这恰好对应了旅行规划这种高复杂度、高耦合、强流程性的场景:机票比价是一个子任务,酒店筛选是另一个子任务,景点路线规划又是一个子任务。每个子任务的判断逻辑、工具依赖、评价指标完全不同,让同一个模型角色通吃所有子任务,必然导致能力和注意力被摊薄。

更重要的是,多Agent架构在工程上给了我们一个关键优势:可观测性和可干预性。单体Prompt是一个黑盒,你很难知道行程出错是哪里出的错;但多Agent框架下,每个子任务有独立模块、独立输出,哪一环出错一目了然。我们后续做调试和效果优化,几乎全是靠这个特性才得以推进的。

2. 把“旅行管家”拆成一支Agent小队:角色设计与编排机制

2.1 角色划分:不是所有任务都该由同一个Agent做

这是整个项目里最核心的设计决策:到底拆成几个Agent、每个Agent负责什么。最初我们团队内部讨论过很多方案,有主张极简的“规划师+执行者”两个角色,也有主张把航班、酒店、餐厅全部拆成独立Agent的“全家桶”方案。最后落地的方案,折中成了五个角色,分别覆盖一次行程规划的核心链路。

Agent角色核心职责关键工具依赖
Planner(规划师)理解用户需求,拆解约束,生成整体行程框架会话记忆、偏好画像
Researcher(研究员)检索目的地信息、景点开放时间、交通衔接方案搜索API、知识库
Bookkeeper(预订管家)查询比价航班、酒店、门票,给出可预订方案票务API、比价工具
WeatherGuard(天气顾问)获取并解释天气预报,评估对行程的影响天气API
Reviewer(质检员)检查生成行程的冲突、超预算、节奏不合理校验规则引擎

每个角色都保持“职能隔离”:Planner不直接调用搜索API,Researcher不直接生成最终行程。这样设计的直接好处是,Prompt可以做得非常专注,不需要在一长串系统提示里告诉模型“你既要做规划又要调用工具还要检查错误”,角色职责越窄,行为越稳定。

2.2 通信协议:Agent之间说什么语言

拆完角色后的第一个问题是:Agent之间怎么通信?最省事的方式是让Agent之间直接用自然语言传消息,就像真人团队在群里聊天一样。但这个方案在实测中立刻暴露了问题:Agent之间的闲聊式沟通会产生大量冗余信息,而且每个Agent对同一句自然语言的理解可能不一致。举个例子,Previous Agent说“后天下午的航班价格有点贵”,Next Agent会无法判断“有点贵”到底贵了多少、需不需要触发改价。

我们的解决方式是定义一套结构化的通信协议,所有消息都带两层结构:自然语言摘要+结构化数据字段。摘要给大模型看,方便理解意图,结构化数据给系统层面做校验和路由。

{ "from": "Researcher", "to": "Planner", "summary": "已确认目标景点本周开放时间,周六9:00-18:00,无闭馆安排", "data": { "poi_id": "poi_101", "open_date": "2025-03-15", "open_time": "09:00", "close_time": "18:00", "status": "confirmed" }, "intent": "update_poi_info" }

这样做的好处是:当Planner读到这条消息时,既可以依赖摘要快速理解内容,又可以调用data字段里的结构化数据做精确判断,避免“可能”“大概”这类模糊表述对下游造成污染。

2.3 编排器与共享状态:谁说了算

有了角色和通信协议,还得有个东西来协调它们。这个角色我们称为编排器(Orchestrator),在实现上它不是一个独立的Agent模型,而是一个运行时分发与状态管理组件。它负责三件事:把用户的初始需求分发给合适的Agent;维护一份全局共享的“当前规划状态”对象;在每轮Agent输出后进行冲突检测和流程推进决策。

这里要特别强调一点:编排器不应该接管用户的对话意图理解。我们第一版尝试让编排器自己做一次“理解用户→生成全局计划→分发子任务”的流程,结果发现编排器变成了一个隐形的单体大脑,其他Agent变成了它调用的函数,本质上又退化成了大Prompt方案。后来调整策略,编排器只做轻量路由和状态维护,对话理解和全局规划仍然由Planner负责。这个调整对系统的模块化程度影响非常大。

3. 从零实现一个可运行的Multi-Agent旅行助手

3.1 技术选型与架构思路

真正动手写代码之前,我们先定了两个原则。第一,不是必须用现成的多Agent框架,关键是团队能理解和维护,跑通流程比赶时髦重要。市面上确实有一些现成的多Agent编排框架,抽象程度高,拿出来就能起一个多Agent演示,但真要接入旅行场景的各种工具API,会发现封装太厚,底层细节被遮住之后出了问题反而难排查。

我们最终的自研架构很轻量:消息总线(用异步队列实现)+注册表(记录每个Agent的职责和工具列表)+共享状态存储(内存态JSON,后续可替换为Redis)+运行时调度逻辑,共四部分。模型层优先选了通用对话模型,后来根据各Agent的侧重做了差异化部署,Planner和Reviewer用强推理型号,其余Agent用兼顾成本和速度的标准型号。

3.2 核心模块的实现要点

先看Agent的基类定义。我们把Agent设计成“一个接收消息、调用工具、产出消息的循环单元”,每个Agent内部由Prompt模板、工具列表、记忆片段三部分组成。

class TravelAgent: def __init__(self, role_name, system_prompt, tools=None, memory_store=None): self.role_name = role_name self.system_prompt = system_prompt self.tools = tools or [] self.memory_store = memory_store async def handle_message(self, message): context = self._build_context(message) tool_results = await self._dispatch_tools(context) response = await self._call_llm(context, tool_results) return self._format_response(response) async def _dispatch_tools(self, context): results = {} for tool_request in context.get("tool_calls", []): tool = self._find_tool(tool_request["name"]) # 统一记录工具调用日志,用于后续调试 results[tool_request["name"]] = await tool.execute(**tool_request["args"]) return results

代码本身不复杂,但有三个细节值得说明:

一是工具调用不直接写在Prompt里让模型自由发挥,而是通过一个独立的ToolCaller中间层统一调度。模型只需要输出“我想查询航班,条件是XX”,系统解析后把它变成结构化工具调用。这样可以防止模型跳过工具、直接编造数据。

二是共享状态存储必须带上版本号。每次修改共享行程状态都会生成新版本,Agent引用状态时带版本号,防止读到旧数据导致行程信息前后矛盾。

三是消息总线里的每一条消息都记录完整的调用链(消息ID、来源、目标、时间戳),这个last-mile日志能力在排障时价值巨大。

3.3 一个完整的请求处理链路:从用户输入到行程生成

用一个实际案例来看整体链路。假设用户输入:“清明节假期想带爸妈去某古城,预算5000,4天3晚,不要太累,爸妈喜欢历史”。

链路第一步,编排器把请求路由给Planner。Planner通过共享状态做约束分析,抽取核心约束:日期范围、人数=2大1老、预算上限、偏好类型=历史文化、节奏=慢。然后Planner生成初期框架,拆出三个子任务:交通查询、住宿筛选、景点路线规划。

第二步,编排器把三个子任务依次分发给对应Agent。交通衔接交给Bookkeeper,它调用票务API获取往返车次与价格,回传选项列表。住宿查询同样由Bookkeeper处理,调用酒店搜索工具。景点规划则分给Researcher,它检索古城主要人文景点、开放时间、建议游览时长。

第三步,各个Agent的回传消息汇聚到共享状态,Planner基于整体状态做整合,生成一份包含交通、住宿、每日行程的完整草稿。草稿不会直接输出,而是先送到Reviewer做冲突检查。

Reviewer在检查时发现了两个问题:一是第一天安排了三个景点,总游览时间加交通衔接时间会超过十小时,和“不要太累”约束冲突;二是酒店报价加上交通支出后,预算余量已经不多,但第三天计划里的特色餐厅人均消费偏高。Reviewer把这两个冲突标注回去,由Planner修正方案后再次送审,通过后才正式输出给用户。

这整条链路在单体Prompt方案里无法显式存在,因为所有动作都发生在一次推理中,中间步骤不可观测,更不可能在输出前做独立的冲突校验。这也是我们后来在宣传这个项目价值时,最常拿来说明的技术差异点。

4. 实测踩坑与调试心得:那些文档里不会写的坑

4.1 Agent之间“信息打架”,根因在共享上下文设计错误

第一个大坑出现在联调阶段:TravelPlanner已经规划好第二天的行程,但Researcher在一次搜索后回传了关于第三天天气的特殊提醒,结果Planner把两天的信息搞混,把第三天的调整应用到了第二天。查日志发现,根本原因是共享状态里存的是“一个完整的每日行程对象”,而非按日期索引的原子对象,Agent在更新时容易整段覆盖。

后来我们把共享状态改成按日期+内容类型双维度存储,每次更新只允许写入最小粒度的字段,比如day2.weather_alert,不涉及day2.attractions的内容就无权写入。这个改动完全消除了覆盖型串改问题。经验是:多Agent系统中的共享状态,一定要设计成“按最小可变单元锁定”的存储模型,而不是一个粗粒度的大JSON,否则越往后改越疼。

4.2 工具调用失败后,Agent学会了“编答案”

这是大模型应用里一个经典陷阱:工具API调用失败时,如果返回值是空的,模型倾向于在回复里保持流畅性,从而“脑补”出看似合理的数据。我们的Bookkeeper在某个票务接口超时后,竟然输出了一张不存在的“早鸟特价机票信息”,震惊了全组人。

解决这些办法上,我们分了三层保障。第一层:工具层,所有工具调用必须返回结构化状态字段,包括success/failure/error_code;处理第二层,Agent的系统Prompt里显式写入“未收到真实数据时,必须声明数据缺失,不得补全”;第三层,编排器在收集到工具失败消息时,自动触发“重试或换源”策略,而不只是把失败信息传给下一个Agent,必要时会中断当前链路,向用户说明“这个信息暂时没查到”。

4.3 记忆污染:上一次的行程计划影响了下一次的规划

有用户连续两次使用助手做旅行查询,中间间隔很短,结果第二次查询时,Planner推荐的景点和餐厅里混入了第一次行程中没有清理干净的偏好痕迹。排查后确认是记忆存储的一个低级设计失误:记忆片段的过期策略没有生效,旧会话的摘要被错误覆盖到了新会话。

我们的修复方案是引入会话级隔离:每个用户session持有独立记忆空间,其中通用偏好(如“用户喜欢历史类景点”)可以跨会话保留,但具体行程细节只能在当前会话内有效。这个“层级化记忆”的设计比简单地在缓存里加个时间戳要可靠得多,因为不是所有记忆都该同等对待。

4.4 评估系统的建立:不能只看生成文本动不动听

多Agent系统最大的评估难点在于:最终输出是一个行程,用户的“满意”由非常多的隐性指标组成。只请人来打分,效率低且主观。我们建立了一个半自动评估流水线,对每条测试样例自动计算几类量化指标。

评估维度指标说明
约束满足率预算、日期、人数、偏好是否全部满足核心指标,不达标的用例不进入人工评审
信息真实性工具调用成功率、数据缺失率检查Agent是否在无数据时编造信息
规划合理性景点地理距离、交通衔接时间、节奏密度用规则引擎对生成的行程做合理性校验
用户交互效率交互轮数、平均等待时长不希望用户为一次查询等太久

这个评估体系上线前,我们的测试方式是肉眼扫答案,很多问题容易被有吸引力的行程文本掩盖;上线后,约束满足率从未达标组的82%提升到92%以上,信息缺失率从11%降到3%,效果非常显著。任何在认真做多Agent系统的人,都应该尽早建立量化评测机制,这能帮你绕开“看起来很好但细看全漏洞”的巨大陷阱。

5. 进阶优化与实际效果:从能跑到好用

5.1 效果对比:单体Prompt vs Multi-Agent

交付前的验收测试里,我们用60个旅行规划测试样本对比了原单体Prompt方案和新的多Agent方案。样本覆盖不同人数、不同预算区间、不同兴趣组合,既有周末短途也有跨省长途。

对比维度单体Prompt方案Multi-Agent方案
约束满足率79%93%
行程冲突产生率34%7%
平均交付时间15.2秒24.8秒
用户后续追问修正率41%23%

数据很清楚:多Agent方案在规划质量上有明显优势,代价是推理链路变长导致响应时间增加,但可以用模型选型、并行调度等方式抵消一部分。这个借鉴关系也印证了我一贯的看法:多Agent并不是为了炫技存在的,它的价值体现在当任务复杂度足够高时,把误差缩小到可控范围。

5.2 低成本进阶技巧:从Mock数据到真实API

很多个人开发者或小团队想复现类似项目,最卡人的是API成本。我们的建议是先全部用Mock数据跑通系统闭环。票务工具返回固定的模拟航班表,天气工具返回预设的几套天气场景,酒店工具返回一个固定候选池。多Agent系统的核心逻辑在于角色拆分、通信协议和编排调度,这些环节不依赖真实数据的复杂度。

用Mock跑通后,再逐个替换真实API。每替换一个,就多跑一遍全部回归测试,确保新的真实数据源不会引入协议不一致。这样的渐进式替换方式有两个好处:一是系统始终处于可运行状态,不会出现过长的空窗期;二是能定位到每个真实API接入时引起的独立问题,排查范围被限制到最小。

5.3 后续还能怎么扩展

目前这套系统已经稳定运行了一个多月,下一步计划的重点有三个方向。

一是用户画像的长期沉淀。现在通用偏好已经能跨会话保留,但还没有形成结构化的用户画像模型,比如“带着老人的家庭出行”“情侣打卡游”“独自特种兵式旅行”这些细分场景,如果画像素能被自动构建和复用,后续规划质量会再上一个台阶。

二是实时动态规划。目前的天气和交通信息是查询时快照式的,但真实旅行中会有航班晚点、景点临时关闭、天气突变等情况。让Agent系统在行程执行期间持续监听事件流,并主动触发重规划,是多Agent架构非常自然的延展方向,也是单体方案最难实现的能力。

三是多语言和跨文化场景适配。目前主要支持中文场景,但如果把Planner、Reviewer的系统Prompt切换到不同地域文化模板,同一套Agent框架可以很方便地迁移到东南亚、欧洲等热门旅行目的地。

这个项目做下来,我最深的体会是:多Agent系统的难点从来不在“多”,而在“Agent”本身有没有被设计清楚。角色边界模糊、通信协议混乱、共享状态粗放,数量再多也只会互相制造噪音。反过来,只要把每个Agent的职责边界、消息格式和状态管理彻底想明白了,系统的能力边界会变得非常清晰。做技术选型或者写代码之前,先把这几个问题回答好,后面自然会顺利很多。

RELATED READING

延伸阅读

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