我构建了一个多智能体旅行规划器

一个AI应用能工作和一个AI应用能可靠地工作之间是有区别的。我是用惨痛的教训学到这一点的。

我启动这个项目时以为会做一些有趣的东西——输入一个旅行请求,返回预算分解,也许还有一些行程建议。一个周末项目。最终我得到的是关于构建生产级多智能体系统真正意味着什么的深度教育,并对照Anthropic自己的Claude认证架构师——基础考试指南进行了验证。

这篇文章将完整梳理整个过程:系统做什么、如何构建、每一个架构决策、我最初在哪里做得不够好,以及我如何修复。如果你正在构建智能体AI系统,其中一些经验会为你节省大量时间。

1、它做什么

你输入类似这样的内容:

多伦多到蒙特利尔,3晚,2人,火车,2026年5月15-18日

系统运行一个四阶段AI管道,每个阶段由一个专门的Claude Haiku代理处理:

  1. 规划器——解析请求,输出一个包含预订URL的结构化旅行清单
  2. 定价器——启动一个真实的Chromium浏览器,运行六次Google搜索,提取实时价格
  3. 预算器——接收原始价格数据,通过计算器工具运行每一次计算(不用心算)
  4. 聚合器——将所有内容合并为一个最终的JSON档案

前端通过Server-Sent Events实时流式传输进度,并以标签页界面呈现结果,包括预算卡片、逐日行程、交通选项和PDF导出。

整个过程端到端运行大约需要90-120秒。

2、技术栈

在讨论架构之前,先看看底层运行着什么:

后端: FastAPI + Python 3.11,全程异步,uvicorn在8000端口

前端: React 18 + Vite 5,开发时代理到后端

AI: Claude Haiku(claude-haiku-4-5-20251001)用于全部四个代理——速度足够快、成本足够低,运行完整管道不会破费

工具: 两个MCP(模型上下文协议)服务器——一个运行Playwright/Chromium用于浏览器自动化,一个运行计算器服务器用于算术运算

存储: SQLite以WAL模式用于可观测性(运行日志、每阶段token计数、工具调用记录)

3、架构:为什么是四个代理?

任何构建多智能体系统的人都必须回答的第一个问题是:为什么不就用一个拥有所有工具的代理?

简短的回答是,拥有许多工具的单代理设置很快就会变得不可预测。当一个模型可以访问浏览器、计算器,同时还负责生成结构化JSON输出时,停止条件变得模糊。模型很容易跳过步骤或混合不同关注点,这使得调试变得困难。

四阶段管道强制执行了一个更重要的东西:基于角色的工具隔离。每个代理只能访问它真正需要的工具:

Planner → no tools (pure reasoning → JSON manifest)
Pricer → browser only (Playwright MCP for Google searches)
Budget → calculator only (financial_quant MCP for arithmetic)
Aggregator → no tools (pure compilation → final JSON)

这在运行时通过config.py中的ROLE_TOOL_SERVERS强制执行。执行器在工具传递给API调用之前按角色过滤可用工具。如果定价器不知何故试图调用计算器工具,它不会出现在工具列表中。

更重要的是,每个阶段的输出是下一阶段的明确输入。没有共享的可变状态。规划器的JSON清单流入定价器。定价器的markdown表格流入预算器。预算器的分级分解流入聚合器。上下文管道是确定性的且可审计的。

阶段1:规划器

规划器接收原始用户消息并输出一个单一的JSON清单。它没有工具——它的全部工作就是解析和推理。

清单如下所示:

{
"trip": {
"origin": "Toronto, Canada",
"destination": "Montreal, Canada",
"depart_date": "2026-05-15",
"return_date": "2026-05-18",
"travellers": 2,
"preferred_transport": "train",
"currency": "CAD",
"budget_tier_preference": "all"
},
"transport_operators": ["VIA Rail Canada"],
"booking_urls": {
"transport": [{"label": "VIA Rail Canada", "url": "…"}],
"accommodation": […],
"activities": […]
}
}

规划器的prompt故意对URL构建规则进行了明确的说明。比如"Greyhound Canada已于2021年5月停止所有运营——永远不要提及它"和"VIA Rail不支持深度链接搜索参数——使用基础URL。"这些领域特定的知识是一个演示和实际可以交给用户使用的东西之间的区别。

我吸取的一个惨痛教训:规划器需要足够的token余量来生成完整的清单。我一度将规划器的MAX_TOKENS减少到512作为成本优化,结果JSON在对象中间被截断。输出可以通过json.loads()正常解析,但缺少booking_urls块。现在设为1024个token——包含所有预订URL的清单可以舒适地放入。

规划器的prompt还包含两个具体的少样本示例——多伦多→蒙特利尔和伦敦→巴黎旅行的完整JSON输出。仅仅这一个添加就使格式合规性比之前的纯文字描述更加一致。

阶段2:定价器

这是最复杂的阶段,也是生产中最可能出现意外行为的阶段。

定价器通过Playwright控制的Chromium实例运行六次结构化的Google搜索。每次搜索遵循严格的顺序:

  1. 主要出发交通(例如,"多伦多到蒙特利尔火车价格 2026年5月15日")
  2. 次要运营商(如果步骤1是火车则用巴士等)
  3. 返程交通
  4. 带精确入住/退房日期的酒店
  5. 带精确日期的Airbnb
  6. 活动 + 餐厅餐费

prompt强制执行的关键约束是:只导航到Google搜索URL。 绝不去运营商网站、酒店预订页面或Airbnb。所有价格数据来自Google的AI概览和搜索片段文本。这是故意的——运营商网站是动态的,需要认证流程,并阻止爬取。Google的搜索结果稳定且快速。

定价器也是我遇到一个更微妙的生产问题的地方:tool_choice。默认情况下,Anthropic API让模型决定是调用工具还是只返回文本。在早期原型中,定价器偶尔会在几次搜索后决定它有"足够的数据",然后在不完成全部六个步骤的情况下产生摘要。修复很简单但很重要——设置tool_choice: {"type": "any"}强制模型在每一轮都调用工具,直到它真正穷尽了搜索计划:

if requires_tool_use:
    kwargs["tool_choice"] = {"type": "any"}

每个使用工具的阶段都采用了这个处理。对于工具使用是主要工作方式的代理来说,这不是可选的。

管理上下文增长

定价器最多运行14轮。每一轮都将浏览器结果添加到对话历史中。如果不加管理,输入token数量在14轮中线性增长——后面的轮子最终要为重新读取所有先前的搜索结果付费,尽管它们不再有用。

修复方案是runner.py中的_trim_old_tool_results()。每次工具调用后,它遍历历史并将旧轮次的工具结果上限设为500字符,仅保留最近一轮的完整结果。模型仍然有这些搜索发生的记录,但不会在第6步时浪费token重新读取第1步的完整4KB HTML提取。

这是定价器阶段token成本降低的最大单一杠杆。节省在轮次间复合增长。

来源追溯

定价器输出表格中的每一行现在都包含一个"来源URL"列——该步骤使用的实际Google搜索URL(或类似"Google → viarail.ca"的说明,当片段明确引用了第三方网站时)。这很重要,原因有几个。用户可以验证数据。下游代理可以对数据质量进行推理。而且它可以暴露Google没有返回有用结果的情况。

阶段3:预算器

预算器有一个在prompt中明确声明的严格规则:不允许心算。 每次计算都通过计算器MCP工具。这不是吹毛求疵——语言模型在多步算术中不可靠,特别是在跨多个旅行者、晚数和四舍五入到现实价格点时进行乘法运算。

预算器的prompt还包含一个关于价格范围的重要反幻觉规则:

当源数据给出一个宽范围(例如"$10-110")时,不要使用绝对最低值作为经济价格——最低值通常是一个罕见的促销/闪购票价。而是使用低典型价格(大约范围的第25百分位)。

这很重要,因为定价器的输出经常包含类似"CAD 10-110"的Megabus路线范围。使用$10作为经济交通数据会产生误导——这个票价存在但并不代表提前一周预订的人实际会支付的价格。第25百分位启发式方法产生的数据是用户可以实际规划的。

预算器在单个响应轮次中批量处理多个计算器调用。这保持了轮次计数较低,避免了每次发送一个算术运算的模式,那样会很快耗尽10轮限制。

输出是结构化的markdown,包含三个级别——经济、中档和舒适——每个显示交通、住宿、餐饮和活动的分解,每个总和都有明确的计算器引用。

阶段4:聚合器

聚合器的工作纯粹是汇编性的。它接收规划器的清单、定价器的markdown表格和预算器的分级计算,输出一个单一的JSON档案。没有工具、没有浏览器、没有计算器——纯粹的合成。

从语言模型获取可靠的JSON输出比听起来更难。天真的方法是指导模型"输出有效的JSON"。这在大多数时候有效,但不总是——模型偶尔会在前面添加解释,将输出包裹在markdown代码围栏中,或在输出接近token限制时省略关闭括号。

聚合器分两个阶段处理这个问题。首先,原始输出通过_try_parse_json(),它剥离markdown围栏并尝试json.loads()。如果失败,系统不是立即使整个运行失败,而是做了一些更有用的事情:它将特定的解析错误附加到prompt中并重试一次。

parsed, parse_err = _try_parse_json(raw_final)
if parsed is None:
    retry_prompt = (
        f"{aggregator_prompt}\n\n"
        f"--- PREVIOUS ATTEMPT FAILED ---\n"
        f"Your previous output could not be parsed as JSON. Error: {parse_err}\n"
        f"Output ONLY valid raw JSON - no markdown, no explanation, no code fences."
    )
    raw_final = await self._runner.run("aggregator", retry_prompt, …)
    parsed, parse_err = _try_parse_json(raw_final)

这个带错误反馈的重试模式是可靠结构化输出的最有效技术之一。模型看到自己的错误和特定的JSON错误消息——这种上下文几乎总是足以自我纠正。

成功解析后,JSON通过_validate_budget_semantics()进行检查。这检查每个预算级别的总计是否与其行项目的总和匹配(5美元容差内)。如果存在差异,它会作为警告记录——不是致命错误,因为货币字符串解析使得精确匹配很脆弱,但值得暴露。

聚合器的prompt包含三个少样本示例:预算级别的正确结构、带完整价格范围的交通条目,以及包含所有三个类别的data_notes块。这些示例对输出一致性的帮助超过任何数量的文字描述。

4、工具层:MCP服务器

浏览器和计算器都通过模型上下文协议(MCP)访问——这是一个用于将工具连接到AI代理的标准接口。MCP服务器作为子进程stdio进程运行,由pipeline.py中的AsyncExitStack管理。

在启动时,TravelAgent.connect()做了一件我最初忽略的事情:它验证每个MCP服务器确实启动并暴露了工具。如果uvx calculator-mcp-server启动失败(例如,uvx未安装),你希望立即发现——而不是在预算阶段在运行90秒后遇到工具调用时:

if n_tools == 0:
    raise RuntimeError(
        f"MCP server '{name}' connected but exposed 0 tools - check server installation"
    )

这种快速失败模式在用户提交请求之前就暴露了配置问题。

执行器层按服务器不同处理超时:浏览器调用45秒(导航可能很慢),计算器调用30秒(算术永远不应该花那么长时间)。两者在失败时都返回结构化的错误JSON:

{
"error": True,
"errorType": "TOOL_TIMEOUT",
"errorCategory": "transient",
"isRetryable": True,
"message": "Tool 'browser_navigate' timed out after 45s. Try again."
}

这很重要,因为模型会读取工具结果。一个带有isRetryable: true的结构化错误给模型足够的信息来决定是否重试调用。一个像"timeout"这样的纯字符串错误则不能。

可观测性

每次管道运行都记录在SQLite中,包含三个表:

  • runs——每次管道执行一行:输入、会话ID、状态、最终JSON、总持续时间
  • agent_calls——每个阶段一行:使用的模型、输入/输出/缓存token、工具调用次数、最终文本
  • tool_calls——每次MCP工具调用一行:工具名称、输入JSON、输出文本(上限5KB)、持续时间、成功标志

这纯粹是可观测性——管道不依赖这些写入。所有数据库操作都包裹在try/except中,失败时静默记录日志,所以SQLite写入错误永远不会导致实时运行崩溃。

token追踪分别包含缓存读取和缓存写入计数。所有系统prompt使用cache_control: {"type": "ephemeral"},这意味着在同一会话中的重复调用,系统prompt从缓存提供而不是重新传输。在一个带有长prompt的四阶段管道中,这在延迟和成本上都产生了有意义的差异。

API层

后端暴露两个主要端点:

POST /api/chat/stream——接受旅行请求,返回SSE流。前端连接并实时接收阶段进度事件,管道完成后接收最终结果事件。

GET /api/latest-run——从SQLite返回最近完成的运行。这为一个测试模式提供动力,前端可以重放结果而无需重新运行管道。

会话管理在以字典为键的内存存储中。这适用于单实例部署。_evict_stale()函数定期断开并清理在过去30分钟内不活跃的会话,使用asyncio.create_task进行尽力断开。

有一个微妙的异步约束值得记录:管理MCP服务器子进程连接的AsyncExitStack必须从进入它的同一个asyncio任务中退出。这是一个anyio内部约束。_safe_disconnect()包装器捕获了当你尝试从不同的任务上下文中关闭堆栈时出现的cancel-scope异常。

5、前端

React前端是用Vite构建的单页应用。在开发中,Vite将/api/*/health代理到8000端口的后端,所以不需要CORS配置。

UI有三个主要状态:输入表单、流式进度视图(实时显示阶段完成情况)和结果视图。结果分为标签页——概览、交通、预算、行程——预算以三个并排卡片呈现(经济/中档/舒适)。

PDF导出通过window.print()实现,使用打印特定的CSS隐藏导航并干净地格式化结果。不需要外部PDF库。

6、哪里未达到生产标准

这是我从中学到最多的部分。

将代码库对照Claude认证架构师——基础考试指南进行评估确实很有启发性。考试指南涵盖五个领域:智能体架构、工具设计、Claude Code配置、提示工程和上下文管理。以下是这个项目早期版本未达到标准的诚实的记录。

缺少强制工具使用

早期的runner.py没有设置tool_choice。模型可以自由跳过工具调用并在决定它有足够信息时返回对话文本。对于定价器来说,本应运行恰好六个搜索步骤,这意味着偶尔得到五步摘要。将其修复为tool_choice: {"type": "any"}使行为变得确定性。

错误对模型不可见

原始执行器在失败时返回纯字符串:"TOOL_ERROR: …"。模型将这些作为工具结果接收,无法区分超时(可重试)和一个不存在的工具(不可重试)。切换到带有errorTypeerrorCategoryisRetryable字段的结构化JSON给模型足够的上下文来做出合理的下一步决策。

静默截断

执行器和管道都在静默地截断结果——浏览器输出在5KB,阶段间传递的价格数据在4KB——没有任何日志条目指示截断已发生。下游代理会接收部分数据,没有迹象表明缺少了什么。现在每次截断都会触发一个_warn()日志,带有截断前后的字符数。如果你在调试奇怪的聚合器输出,可以检查它接收的价格数据是否已经被截断。

JSON解析失败时无重试

如果聚合器产生格式错误的JSON,整个运行就会失败。带错误反馈的重试循环是最大的单一可靠性改进——在格式错误输出的罕见情况下,它只多花费一次API调用,并避免了用户必须手动重新提交的完整运行失败。

MCP服务器故障发现太晚

没有启动健康检查,缺少的uvx二进制文件或失败的npx命令会在运行90秒后以神秘的错误出现,此时规划器和定价器已经完成。connect()时的健康检查使故障立即出现,错误消息也有用。

没有少样本示例

原始的prompt用文字描述输出格式。向规划器添加两个具体示例和向聚合器添加三个明显改善了首次格式合规性。这是提示工程研究中一个众所周知的发现,我最初太懒没有付诸行动。

7、考试指南正确的地方

Claude认证架构师——基础考试指南围绕五个领域构建,用这个代码库作为案例研究逐一检查产生了否则我不会做出的更改。

领域1(智能体架构)tool_choice和重试循环的来源。关于基于角色的工具隔离的指导验证了我已经构建的设计,但将轮次限制作为安全网的框架是新的——我之前一直将MAX_TURNS作为主要停止机制而非故障安全。

领域2(工具设计)产生了结构化错误元数据和MCP健康检查。工具错误需要是机器可读的而非人类可读的这一具体框架很有用。

领域4(提示工程)是少样本示例的来源。指南的观点——2-4个具体示例比非常详细的文字描述在一致的结构化输出方面表现更好——推动我完成了这项工作。

领域5(上下文管理)是历史修剪和可见截断警告的来源。关于"迷失在中间"风险的观点——放置在长prompt中间的关键发现比开头或结尾的发现被关注得不太可靠——是我没有仔细思考过的东西。

8、Claude Code配置

项目使用Claude Code作为开发环境,有一些值得提及的特定配置。

仓库根目录的CLAUDE.md记录了架构、文件映射、关键不变量(包括如果你遗漏它会咬你的anyio cancel-scope约束)、常见陷阱(Playwright未安装、找不到uvx、首次npx运行缓慢)以及SSE流协议。此文档自动加载到Claude Code上下文中。

.claude/skills/包含deploydestroytest-local的自定义斜杠命令。test-local技能处理完整的本地E2E测试周期:杀死8000和5173端口上的现有进程,启动后端和前端,通过curl运行完整的管道请求,评估SSE流,然后停止一切。

.claude/settings.json具有文件权限和工具访问的项目范围配置。

自己运行

# Clone and install
git clone <repo>
uv sync
# Install Playwright browser
uv run playwright install chromium
# Set API key
echo "ANTHROPIC_API_KEY=sk-ant-…" > .env
# Start backend (port 8000)
uv run uvicorn app.api:app --reload --log-level info
# Start frontend (port 5173, separate terminal)
cd frontend && npm install && npm run dev

导航到http://localhost:5173,输入旅行请求,观看四个阶段实时完成。

如果找不到uvx,使用pip install uvbrew install uv安装。计算器MCP服务器需要它。

9、部署

应用部署在AWS上,通过infra/中的TypeScript CDK堆栈进行配置。

架构故意简单。一个单独的EC2 t2.micro实例在从ECR拉取的Docker容器中运行FastAPI后端。一个CloudFront分发放在它前面——HTTPS终止,全局边缘缓存禁用(管道结果是按用户的且不可缓存的),以及一个60秒的读取超时,在漫长的Playwright定价阶段通过CloudFront保持SSE连接活跃。

DynamoDB在生产中替代SQLite,使用三个镜像相同模式的表:tripai-runstripai-agent-callstripai-tool-calls。三个都是按需计费(PAY_PER_REQUEST)——流量模式是突发和不可预测的,所以配置容量只会浪费钱。EC2实例角色获得所有三个表的读写权限加上SSM访问权限用于密钥管理。

React前端被烘焙到Docker镜像中并由FastAPI应用直接提供服务——静态资产没有单独的S3/CloudFront源。这保持了部署面很小:一个ECR镜像、一个EC2实例、一个CloudFront分发。对于当前的流量量,这是正确的权衡。

我会做的几个改进随着项目的扩展:

每个数据点的置信度分数—— 聚合器目前对所有价格数据一视同仁。从Google AI概览提取的价格比从引用可能是过时网站的片段中解析的价格更可靠。每个价格条目上的confidence字段可以将低置信度结果路由到人工审核队列。

带重试的语义预算验证—— _validate_budget_semantics()目前只记录警告但不重试。将预算验证失败连接到与JSON解析失败相同的带错误反馈重试循环将关闭剩余的可靠性差距。

路径特定的Claude规则—— 所有约定目前存在于一个CLAUDE.md中。拆分为.claude/rules/python.md.claude/rules/react.md并使用glob加载将在处理堆栈的一个部分时减少不相关的上下文。

10、最后的想法

构建这个教会了我一些意想不到的东西:一个能工作的AI原型和一个可靠的AI系统之间的差距几乎完全在于模型周围的那些无聊基础设施——错误处理、结构化工具响应、上下文管理、强制输出格式合规。

AI部分(让Claude规划一次旅行)很容易。让它总是返回有效的JSON,总是完成它的六个搜索步骤,以有用的而非神秘的方式暴露错误,并且在管道中间不静默丢失数据——那需要显著更多的工作。

Claude认证架构师考试指南是审计这个差距的好框架。不是因为它告诉你任何根本性的新东西,而是因为它迫使你回答关于你的系统的具体问题,这些问题在你专注于快乐路径时很容易跳过。

如果你正在构建类似的东西,投资回报率最高的三个更改是:为使用工具的代理强制tool_choice: "any",为结构化输出添加带错误反馈的重试,以及在每个截断点添加监控。其余的都是优化。

完整源码可在GitHub上获取。项目使用Claude Haiku、FastAPI、React和两个MCP服务器——通过Playwright进行浏览器自动化和通过计算器服务器进行算术运算。通过CDK堆栈部署在AWS(EC2 + CloudFront + DynamoDB)上。按当前Haiku定价,完整管道运行(4个阶段,约6次浏览器搜索)的总成本约为$0.01-0.02。


原文链接:How I Built a Multi-Agent AI Travel Planner

汇智网翻译整理,转载请标明出处