承接上文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.pybackend/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 时,极度容易**“丢三落四” 如果没有 Optionaldefault,缺少一个字段就会导致整体验证失败。使用了这些特性后,系统实现了优雅降级—— 核心字段(带 ... 的必填项,比如名字、日期)必须有,非核心字段没有就塞个默认值 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,
                # ...
            }
        }

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐