Helloagents-13travel agent学习笔记
承接上文Helloagents-13.智能旅行助手学习笔记 _helloagents旅游项目-CSDN博客
1.全链路架构梳理
1. 订单接入(用户输入 -> 后端接收)
-
前端 (Vue): 用户在网页上填好目的地(如“悉尼”)、天数、出发日期等,点击“生成”。数据打包成一个 HTTP POST 请求发给后端。
-
后端的看门大爷 (Pydantic): FastAPI 接收到请求后,第一时间交给
Pydantic。它会检查数据格式对不对(比如天数必须是整数,目的地不能为空)。如果不符合,直接打回;符合,则放行进入核心车间。
2. 原材料采购(绕过 LLM,Python 强行“截胡”调 API)
(这是我改的)
-
原本的开源设计是让大模型自己去看工具说明书(Function Calling)调高德地图。但因为 MCP 工具链传参丢失(空字典
{}问题),大模型不会用工具了。 -
重构了架构:Python 后端直接接管了“采购权”。修改
amap_service用硬编码直接去请求高德 API,稳定地拿到了最真实的目的地天气、POI(兴趣点)和路线原生 JSON 数据。
3.图片素材补充(Serper 搜图引擎)
-
有了高德的文字数据还不够,由于 Unsplash 对中文景点支持极差,改用serper替代unsplash搜图。新建了
serper_service.py,根据高德给出的景点名称,通过 Serper API 精准抓取真实的景点图片 URL。
4.翻译官与排版员(调度大模型 LLM)
-
Python 后端把从高德拿到的真实地理数据、从 Serper 拿到的景点图片链接,以及用户的输入,打包塞进设定好的 Prompt 里。
-
大模型根据这些原材料,生成一份标准的长 JSON。里面包含了每日的行程安排、交通推荐和基础的费用估算。
5. 结构质检(JSON 解析与响应组装)
-
后端用 Pydantic 校验大模型吐出的中文行程 JSON。检查它是否严格包含前端所需的字段(如
days,attractions,location等)。如果解析成功,就把这串标准的 JSON 响应发送给前端。
6. 页面渲染(前端展示)
-
Vue 页面接单: 前端拿到数据后进行解析。
-
地图联动: 提取出 JSON 里的经纬度或地点信息,传给高德地图前端组件(Map.vue),在右侧渲染出真实的交互式地图路线。
-
图文排版: 在左侧将每日行程、Serper 抓取到的真实景点图片、以及基础的预算信息渲染成网页列表。
2.Bug总结
1.MCP 工具链瘫痪
-
bug: 在让大模型生成旅行计划时,系统直接报错,大模型回复:“抱歉,工具不可用”。
-
深层根本原因是LLM的依赖性: 底层的
amap-mcp-server存在 Bug,在向大模型注册工具时,传递的参数说明书是个空字典{}。大模型虽然聪明,但它没有参数说明,它根本不知道怎么构造高德地图的 API 请求,导致 Function Calling 整个流程直接断裂。 -
解决方案:放弃了让 LLM 自主调度工具。用 Python 代码做拦截,直接硬编码去调用高德 API 获取真实的 JSON 气象和地理数据;然后将这些数据作为“上下文知识”,拼接在 Prompt 里喂给大模型。大模型从“手握方向盘的司机”降级成了“专业的翻译官和排版员”,从架构上消除了工具调度的不稳定性。
2.Unsplash 的“语言隔离”与翻译拦截
-
bug: 在前端页面上,行程列表渲染出来了,高德地图也显示了,但是像“夫子庙”、“玄武湖”这样的景点,配图要么完全空白,要么显示默认的错误图片。
-
深层根本原因是API 语境偏差: 系统原本调用的是国外的 Unsplash 免费图库。这个图库的检索引擎是纯英文语境的。当系统傻乎乎地把高德地图返回的中文景点名(如“夫子庙”)直接当作参数塞给 Unsplash API 时,对方根本无法解析,直接返回空结果(0 hits)。
-
解决方案: 在
poi.py调用图片 API 之前,强行插入了一个翻译层(数据预处理)。先调用大模型(或翻译工具)将中文 POI 翻译成准确的英文地名,然后再去请求 Unsplash。这体现了在对接第三方跨国 API 时,必须做的数据标准化处理。
3.图库“幻觉”与 Serper 引擎的彻底替换
-
bug:翻译层加上之后,图片确实出来了。但是当搜索南京“水游城”时,前端显示了一张北京“水立方”的照片;搜索某些小众景点时,出来的全是风马牛不相及的风景图。
-
深层根本原因是数据源覆盖率缺陷:这是第三方数据源先天不足。Unsplash 作为一个海外的无版权图库,它对中国本土化垂直场景(尤其是具体的地理 POI)的覆盖率极低。当它找不到精确匹配时,它的底层搜索算法会进行“宽泛匹配”(比如看到“水”就推水立方),导致前端出现幻觉。
-
解决方案:改用了
Serper。新建了serper_service.py,并在config.py中重构了环境变量。在poi.py中切断了旧图库的引用,实现了业务代码的无缝切换。
3.核心代码-伪代码总结逻辑
1.Prompt 的动态编排层
backend/app/agents/trip_planner_agent.py
- 拦截llm,硬编码调用高德api
# 提取自 _search_attractions 等工具调用函数 # 我们不再把工具丢给 agent.run,而是直接用 self.amap_tool.run 传死参数 result = self.amap_tool.run({ "action": "call_tool", "tool_name": "maps_text_search", "arguments": { "keywords": keywords, "city": request.city, "citylimit": "true" } }) -
prompt拼装
# 提取自 _generate_plan_with_llm # Python 用 f-string 把真实的高德 JSON 强行嵌入到了用户的上下文中 query = f"""请根据以下真实数据生成{request.city}的{request.travel_days}天旅行计划: ... **景点数据 (JSON):** {json.dumps(attraction_data, ensure_ascii=False, indent=2)} **天气数据 (JSON):** {json.dumps(weather_data, ensure_ascii=False, indent=2)} ... """ # 最后直接 invoke 裸调大模型,没有 agent 调度,只有最纯粹的对话 messages = [ {"role": "system", "content": PLANNER_PROMPT}, {"role": "user", "content": query} ] response = self.llm.invoke(messages) -
planner_prompt
1:少样本提示
代码体现: 直接给了大模型一个完整的带缩进的 JSON 结构模板(如 "location": {"longitude": 116.397128, "latitude": 39.916527})。
架构意义: 相比于用自然语言说“请输出包含经纬度的地点信息”,直接给一个模板,能让大模型的 JSON 输出准确率提升 90% 以上。
2:防御性边界指令
代码体现: 温度必须是纯数字(不要带°C等单位)
架构意义: 因为后端的 Pydantic Schema 里,温度的类型大概率定义成了 int。如果大模型输出 "25°C",后端就会直接报格式验证错误。这句 Prompt 从源头上掐断了解析崩溃的风险。
3:密集的显式约束
代码体现: 每天安排2-3个景点、每天必须包含早中晚三餐
架构意义: 防止大模型“偷懒”或“产生幻觉”。大模型经常会省略输出,通过明确的数量限制,逼迫大模型进行充分的上下文推理,保证行程内容的饱满度。
trip_planner_agent.py的架构:
trip_planner_agent.py (多智能体旅行规划器核心逻辑)
│
├── 顶层常量与配置
│ └── PLANNER_PROMPT (全局常量:定义系统级人设与极严谨的 JSON 模板约束)
│
├── class MultiAgentTripPlanner (核心业务类)
│ │
│ ├── 1. 初始化层
│ │ └── __init__ (加载大模型 llm,并注册绑定 amap_mcp_server)
│ │
│ ├── 2. 核心调度主流程 (Facade 模式)
│ │ └── plan_trip (对外唯一公开的方法,像指挥官一样调度下面的私有方法)
│ │ ├── 调用 _search_attractions (抓取景点)
│ │ ├── 调用 _get_weather (抓取天气)
│ │ ├── 调用 _search_hotels (抓取酒店)
│ │ ├── 调用 _generate_plan_with_llm (丢给大模型翻译)
│ │ └── 调用 _parse_response (解析返回结果)
│ │
│ ├── 3. 工具拦截调用层 (硬编码拿数据)
│ │ ├── _search_attractions (调高德地点搜索)
│ │ ├── _get_weather (调高德天气 API)
│ │ └── _search_hotels (调高德搜酒店)
│ │
│ ├── 4. 数据预处理层
│ │ └── _parse_mcp_result (剥离工具返回的无效字符,只提取 JSON)
│ │
│ ├── 5. 核心推理层
│ │ └── _generate_plan_with_llm (组装 System 模板 + User 真实数据,调 LLM)
│ │
│ └── 6. 兜底与容错层
│ ├── _parse_response (正则剥离大模型返回的 ```json 等脏数据)
│ └── _create_fallback_plan (如果大模型罢工,强行用 Python 写死一个兜底行程)
│
└── 全局单例实例化
├── _multi_agent_planner (全局缓存变量)
└── get_trip_planner_agent() (单例模式入口,确保全局只初始化一次高德工具)
2.外部API的调度与清洗层
backend/app/services/amap_service.py
- 清洗烂字符串,修复 Pydantic 报错:在服务层做了一层正则拦截。利用 re.search(r'\{.*\}') 配合 re.DOTALL(允许匹配换行符),把中间真正的 JSON 块抠了出来,再用 json.loads 转成标准的 Python 字典。
import json import re # 核心清洗逻辑:使用正则表达式强行提取大括号包裹的内容 json_match = re.search(r'\{.*\}', result, re.DOTALL) if json_match: # 提取纯净的 JSON 字符串,并转化为 Python 字典 data = json.loads(json_match.group()) return data # 如果怎么都提不出来,返回一个带 raw 标记的字典兜底 return {"raw": result} - 只初始化一次高德地图MCP工具
global _amap_mcp_tool if _amap_mcp_tool is None: _amap_mcp_tool = MCPTool(...)
backend/app/services/serper_service.py 和 backend/app/api/routes/poi.py
- 多级搜索策略。通过动态追加 Context(如‘景点’、‘landmark’)进行多轮重试。如果三轮都查不到,程序会果断返回 None:
# 策略1: 原名直搜
photos = self.search_photos(name, num=3)
# 策略2: 加 "景点" 限定词(Query Expansion)
photos = self.search_photos(f"{name} 景点", num=3)
# 策略3: 跨语言限定词
photos = self.search_photos(f"{name} landmark China", num=3)
# 终极防御:宁缺毋滥
return None
-
路由与服务解耦:
poi.py 里面只有 @router.get,它自己绝对不去发 HTTP 请求,而是老老实实调用 serper_service.get_photo_url(name)。
这叫职责分离。poi.py(Controller 层)只负责接客和参数校验;serper_service.py(Service 层)专心干苦力查数据。以后如果要换百度识图,只需要改 Service 层,完全不需要动 API 路由层。
-
数据清洗
# 没有直接 return response.json() for photo in results: photos.append({ "url": photo.get("imageUrl"), "title": photo.get("title") }) -
标准化的统一响应结构(poi.py中)。所有的 API 接口都必须遵守
{"success": bool, "message": str, "data": Any}的结构。这样前端在写 Axios 拦截器时,就能非常轻松地全局处理成功和失败的弹窗,不用每个接口都去猜数据在哪一层。return { "success": True, "message": "获取图片成功", "data": { ... } }
3.大模型输出的兜底与解析层
backend/app/api/routes/trip.py
- 控制器:lan_trip 函数内部几乎没有写任何具体的 if-else 业务逻辑,它只是打印了日志,然后直接把请求扔给了 agent.plan_trip(request),拿到结果后立马包装返回。真正的“炒菜”全在后面的 Agent 和 Service 层。这样写,代码清爽,维护时一目了然。
- GET /health接口:它不仅返回了 "status": "healthy",还主动去试探了一下底层的 agent 能不能正常获取(len(agent.agent.list_tools()))。如果底层 MCP 工具挂了,这个接口会立刻抛出 503 (Service Unavailable)。
backend/app/model/schemas.py
-
之前在 Prompt 里嘱咐大模型:“温度必须是纯数字”。但是, 它还是可能由于幻觉,输出了 "day_temp": "25°C"。
如果不用 @field_validator,Pydantic 看到字符串 "25°C" 而不是整数,会当场抛出 ValidationError,整个行程规划直接崩溃。
mode='before'(在 Pydantic 原生类型检查之前拦截)相当于在海关门口放了一个“自动清洗机”,不管大模型吐出来带什么单位,全给你扒干净转成整数。柔性容错机制
@field_validator('day_temp', 'night_temp', mode='before') @classmethod def parse_temperature(cls, v): """解析温度,移除°C等单位""" if isinstance(v, str): v = v.replace('°C', '').replace('℃', '').replace('°', '').strip() try: return int(v) except ValueError: return 0 return v -
自定义数据清洗器:大模型在生成超长 JSON 时,极度容易**“丢三落四”。 如果没有
Optional和default,缺少一个字段就会导致整体验证失败。使用了这些特性后,系统实现了优雅降级—— 核心字段(带...的必填项,比如名字、日期)必须有,非核心字段没有就塞个默认值0或者None,绝不影响主流程。前端拿不到rating大不了就不显示星星,但绝不会白屏。rating: Optional[float] = Field(default=None, description="评分") photos: Optional[List[str]] = Field(default_factory=list, description="景点图片URL列表") ticket_price: int = Field(default=0, description="门票价格(元)") -
嵌套树状契约 :
代码体现:从下往上组装:Location -> Attraction/Hotel/Meal -> DayPlan -> TripPlan -> TripPlanResponse。
架构意义:不要企图用一个几百行的巨大字典去约束复杂的返回值。这种一层套一层的组合模式,不仅让代码可读性极强,更重要的是,当大模型的某一行 JSON 出错时,Pydantic 报错堆栈会极其精准地告诉你:“是 TripPlan 里的第 2 个 DayPlan 里的第 1 个 Attraction 的 Location 格式错了”。这极大地降低了后期的 Debug 成本。
-
API 的“自说明”与 Swagger 集成:这是标准的大厂 API 规范。加上
example和详细的description之后,FastAPI 会在/docs路径下自动生成一个可以直接进行调试、并且自带样例数据的炫酷 Swagger 接口文档。前后端联调时,前端只需要看这个自动生成的文档,根本不用问“这个字段是干嘛的”。class Config: json_schema_extra = { "example": { "city": "北京", "travel_days": 3, # ... } }
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)