# 使用LangChain+LangGraph开发AI应用（12） - 案例：个人旅游助手


![](https://static.xiongneng.me/langchain-langgraph-cover-20261017205458.png)

## 学习进度

最后一篇，把整套技术串成一个能出门用的东西，个人旅游助手。你说一句「十一去杭州玩两天，不吃辣，喜欢博物馆」，它查天气、翻攻略、结合你的偏好排出行程，存成 markdown 文件，顺手把你的偏好记进长期记忆，下次再规划时不用重新交代。

这个项目用到的全部是前十一篇的零件，工具调用来自第 4 篇，create_agent 来自第 5 篇，长期记忆来自第 7 篇，编排与循环保驾来自第 8、9 篇。代码在 `source/travel.py`，两百五十行左右，五个工具一个 agent，配 `mems` 命令翻看记忆。没有 API key 也能完整演示，替身模型的四步脚本走真工具。

## 前言用途

旅游规划是检验 agent 能力的好场景，因为它天然踩中三件事。

信息是分散的，天气在一个地方、景点攻略在另一个地方、你的偏好在你脑子里，单靠一次模型调用谁也答不全，必须多工具协作。信息是时间敏感的，下雨天和晴天该去的地方完全不同，行程必须跟着实时数据走，不能让模型凭训练记忆编。需求是因人而异的，同样去杭州，爱吃辣和不吃辣、带娃和独行，行程天差地别，这些差异必须沉淀成记忆，而不是每句话里重复一遍。

三个约束各自对应一个部件。信息分散交给多工具，时间敏感交给工具查实时数据，因人而异交给长期记忆。这个对应关系就是本篇的骨架，也是绝大多数帮用户办事的 agent 产品的骨架，换个行业换套数据，结构不动。

## 原理概念

先说模拟数据的取舍。案例里的天气和景点攻略是本地字典，不调任何外部 API。这样选有三个理由，演示和测试零成本，谁克隆仓库都能跑；行为完全确定，天气永远是多云转小雨，测试断言写得踏实；工具契约清晰，入参出参就是未来对接真实 API 的接口。真实项目里把函数体换成高德、和风的 HTTP 调用，模型看到的工具描述一个字不用改。模拟数据是一种教学选择，它把工具契约和数据来源解耦了，换数据源等于换函数体。

长期记忆用第 7 篇的 InMemoryStore，namespace 固定 `("travel", "default-user")`。读写各是一个工具，remember_preference 把键值对 put 进去，recall_preferences 把整个 namespace search 出来。偏好是用户说出来的（不吃辣），也可能是 agent 主动留档的（last_plan_杭州），两类记录共用一个 namespace，按 key 前缀区分。InMemoryStore 不跨进程，真实项目换 PostgresStore，工具代码一行不动，这是 store 接口设计带来的好处。

行程落盘是「模型的产出要变成文件」这件事的范本。save_itinerary 工具做两个动作，把行程文本写进 `travel_plans/杭州.md`，把摘要 put 进长期记忆。写文件让产出可留存可分享，留档让下次对话能引用上次的结果。这一步要做成工具交给模型调用，什么时候存、存什么版本，是模型根据对话进度判断的，代码预先写死反而接不住对话的变数。

![](https://static.xiongneng.me/travel-arch-20261017205600.png)

## 核心用法

五个工具和一个组装函数，逐个过。

### 1. 信息工具

天气和攻略是纯读的模拟数据工具，docstring 讲清楚返回什么。

```python
WEATHER = {
    "杭州": [("第一天", "多云", "22", "东南风 2 级"),
             ("第二天", "小雨", "19", "东风 3 级")],
}

@tool
def get_weather(city: str) -> str:
    """查一个城市未来两天的天气预报，返回日期、天气、气温与风向。"""
    forecast = WEATHER.get(city)
    if not forecast:
        return f"没有 {city} 的天气数据，现有城市：{'、'.join(WEATHER)}"
    return "\n".join(f"{d}：{w}，{t}℃，{wind}" for d, w, t, wind in forecast)
```

```text
第一天：多云，22℃，东南风 2 级
第二天：小雨，19℃，东风 3 级
```

查不到的城市如实告知并列出现有城市，这个兜底直接决定模型的下一步。真实模型实测时问了一个不在字典里的城市，它收到这句提示后换了种问法，没有硬编一段天气。景点工具同构，多一个 tag 参数支持室内、人文这类过滤，雨天找室内景点就靠它。

对接真实数据源时只动函数体，工具签名和 docstring 原样保留。

```python
@tool
def get_weather(city: str) -> str:
    """查一个城市未来两天的天气预报，返回日期、天气、气温与风向。"""
    resp = httpx.get("https://api.example.com/v1/weather",
                     params={"city": city},
                     headers={"Authorization": f"Bearer {API_KEY}"})
    resp.raise_for_status()
    daily = resp.json()["daily"][:2]
    return "\n".join(f"{d['date']}：{d['condition']}，{d['temp']}℃"
                     for d in daily)
```

模型不关心数据从哪来，它只依赖 docstring 描述的契约。这套接口稳定之后，模拟测试和真实调用可以并存，环境变量一个开关切换，测试永远走模拟，线上走真 API。

### 2. 长期记忆工具

remember 与 recall 是一对，一个写一个读，共用同一个 namespace。

```python
MEM_NAMESPACE = ("travel", "default-user")

@tool
def remember_preference(key: str, value: str, runtime: ToolRuntime) -> str:
    """把用户的旅行偏好写进长期记忆，例如 不吃辣、喜欢博物馆、预算两千。"""
    runtime.store.put(MEM_NAMESPACE, key, {"key": key, "value": value})
    return f"已记住偏好 {key}={value}"

@tool
def recall_preferences(runtime: ToolRuntime) -> str:
    """取回全部旅行偏好，规划行程前先看一眼。"""
    items = runtime.store.search(MEM_NAMESPACE)
    ...
```

```text
饮食=不吃辣
兴趣=博物馆与安静的地方
预算=两千
```

runtime 参数是框架注入的（第 4 篇讲过的保留字参数），store 由 create_agent 传进来，工具函数里拿现成的用。recall 的返回就是几行键值对，模型读得懂。空记忆有兜底文案，第一次对话不至于让模型猜。

`mems` 命令给记忆开个人读的窗口。

```bash
uv run python travel.py mems
```

```text
饮食=不吃辣
兴趣=博物馆与安静的地方
预算=两千
last_plan_杭州 -> # 杭州两日行程（十一期间）偏好：不吃辣 · 喜欢...
```

前缀约定在这里发挥作用，纯偏好直接用 key，agent 自己留的档以 last_plan 开头，一眼分清哪些是用户说的、哪些是系统记的。

### 3. 行程落盘工具

save_itinerary 是全案唯一有副作用的工具，写文件加记忆留档。

```python
@tool
def save_itinerary(city: str, text: str, runtime: ToolRuntime) -> str:
    """把最终行程写成 markdown 文件存进 travel_plans 目录，并在长期记忆留档。"""
    PLAN_DIR.mkdir(exist_ok=True)
    path = PLAN_DIR / f"{city}.md"
    path.write_text(text, encoding="utf-8")
    runtime.store.put(MEM_NAMESPACE, f"last_plan_{city}", {"text": text[:80]})
    return f"行程已保存到 {path.name}，并在长期记忆留档"
```

返回值把落盘路径告诉模型，模型的最终回答就能引用文件位置。留档只存前八十字符，记忆是索引不是备份，全文在文件里，两边各司其职。

### 4. 组装与替身脚本

五个工具挂进 create_agent，store 一起传进去，一个函数装完。

```python
TOOLS = [get_weather, search_attractions,
         remember_preference, recall_preferences, save_itinerary]

def build_agent(store, model):
    return create_agent(model, tools=TOOLS, store=store, debug=False)
```

无 key 演示用四步脚本，先查偏好、再查天气、再查景点、最后存行程，每一步都是模型该有的动作，工具全部真执行。

```python
def fake_model() -> ScriptedChatModel:
    return ScriptedChatModel(messages=iter([
        AIMessage(content="", tool_calls=[
            {"name": "recall_preferences", "args": {}, ...}]),
        AIMessage(content="", tool_calls=[
            {"name": "get_weather", "args": {"city": "杭州"}, ...}]),
        AIMessage(content="", tool_calls=[
            {"name": "search_attractions", "args": {"city": "杭州"}, ...}]),
        AIMessage(content="", tool_calls=[
            {"name": "save_itinerary", "args": {"city": "杭州", "text": "..."}, ...}]),
        AIMessage(content="杭州两日行程已生成并存档。"),
    ]))
```

```text
协作轨迹:
  [HumanMessage] 十一去杭州玩两天
  [AIMessage] 调用 recall_preferences({})
      = （还没有任何偏好记录）
  [AIMessage] 调用 get_weather({'city': '杭州'})
      = 第一天：多云，22℃ / 第二天：小雨，19℃
  [AIMessage] 调用 search_attractions({'city': '杭州'})
      = 西湖（山水）/ 灵隐寺（人文）/ 丝绸博物馆（室内）
  [AIMessage] 调用 save_itinerary({...})
      = 行程已保存到 杭州.md，并在长期记忆留档
（共 4 轮工具调用）
```

替身的好处是每一步的输入输出都看得到，测试断言写得进每个环节，十条测试零 API 成本，覆盖见完整案例一节。

## 完整案例

接真实模型跑一把，需求把偏好一次说完。

```bash
AGENT_MODEL=openai:deepseek-v4.1-flash \
uv run python travel.py plan "我不吃辣，喜欢博物馆和安静的地方，预算两千，十一去杭州玩两天"
```

带 --trace 跑，第一轮就看出门道。

```text
协作轨迹:
  [AIMessage] 调用 recall_preferences({}), remember_preference({'key': '饮食', 'value': '不吃辣'}),
      remember_preference({'key': '偏好', 'value': '喜欢博物馆、安静的地方...'}),
      remember_preference({'key': '预算', 'value': '两千'}), get_weather({'city': '杭州'}),
      search_attractions({'city': '杭州', 'tag': '人文'}), search_attractions({'city': '杭州', 'tag': '室内'})
      = 饮食=不吃辣 / 预算=两千
      = 已记住偏好 饮食=不吃辣（共三条）
      = 第一天：多云，22℃ / 第二天：小雨，19℃
      = 灵隐寺（人文）/ 中国丝绸博物馆（室内）
  [AIMessage] 调用 search_attractions({'city': '杭州', 'tag': '山水'})
      = 西湖（山水）：环湖步行三小时，断桥到雷峰塔一线最经典
  [AIMessage] 调用 save_itinerary({'city': '杭州', 'text': '# 杭州两天行程（十一）...'})
      = 行程已保存到 杭州.md，并在长期记忆留档
（共 3 轮工具调用，第一轮并行 7 个）
```

第一轮一口气并行了七个调用，取记忆、把用户说出的偏好写进长期记忆、查天气、按人文和室内两个标签分开查景点。这批调用彼此不依赖，引擎一轮跑完，第 8 篇的 super-step 在这里真实兑现。第二轮补一个山水标签查西湖，因为它预判行程需要户外选项；第三轮存行程。三次分组，每次都只发该发的调用，多工具协作的精髓是按需，不是每轮全查一遍。

```text
（共 3 轮工具调用）
已经帮你排好并保存了（travel_plans/杭州.md），也记住了你的偏好。

关键判断：第二天有雨，所以室内项目放第二天。

Day 1｜多云 22℃
- 07:30–11:30 灵隐寺 + 飞来峰（十一一定赶早，寺内素面不辣）
- 13:30–17:00 西湖环湖步行：断桥 → 白堤 → 平湖秋月

Day 2｜小雨 19℃
- 09:00–12:00 中国丝绸博物馆：免费、室内、安静，雨天首选
- 下午雨小就走一段玉皇山脚下

预算：住宿 600 + 餐饮 300 + 市内交通 100 + 门票约 100
+ 机动 300 ≈ 1400 元，在两千以内。

吃：杭州本地口味清淡，避开川湘菜馆，点单时说清不要辣椒。

一点透明的说明：杭州景点库这次只返回了三条，行程是围绕它们
做的，想要更冷门的替代我可以再查一轮补充。
```

这段输出值得看三处。偏好没有被复述一遍就完了，饮食、兴趣、预算全部化进了具体安排，素面、博物馆、预算拆算各归各位。天气决策是显式的，「第二天有雨所以室内放第二天」单独成行，判断依据可核验，这正是工具查回的真实数据在驱动行程。最后那段透明说明最见功力，模型知道自己只拿到了三条景点记录，主动交代行程的边界，检索工具的空结果与兜底文案在这里兑现成了诚实。

落盘的行程文件也带着完整的决策上下文。

```text
# 杭州两日行程（十一期间）

偏好：不吃辣 · 喜欢博物馆与安静的地方 · 预算 2000 元
天气：Day1 多云 22℃｜Day2 小雨 19℃ → 把室内项目放在第二天

## Day 1（多云 22℃，适合户外）
- 07:30–11:30 灵隐寺 + 飞来峰（人文）
```

文件开头两行写明偏好与天气依据，一个月后打开这个文件，依然能看懂行程为什么这么排。mems 命令翻看长期记忆，last_plan_杭州 的留档就在里面，下次对话模型先 recall 就能接着上次聊。

测试在 `tests/test_travel.py` 十条，天气兜底、tag 过滤、记忆往返、行程落盘、替身四步全程、工具清单，全走本地模拟。加上前十篇的，全仓一百八十七条，全绿。

到这里十二篇全部完结。回顾整个系列的地图，前两篇立骨架、装模型，第三、四篇打通消息与工具，第五篇把 agent 拧成整机，第六、七篇补上检索和记忆，第八、九篇学会画图并给图装上持久化与人工协同，第十篇把多个 agent 排进一张图，最后两篇用两个真实项目把所有零件串起来。每一篇的示例都能独立运行，两个案例篇的项目直接可用。

想继续往下走，方向也都在这个仓库里铺好了。给旅游助手接高德地图的真实 API，把行程做进第 9 篇的审批流程，去钉一个景点前先问你一句；把知识库和旅游助手挂进同一个 supervisor（第 10 篇），一个入口回答两类问题；第 11 篇的引用溯源搬到这里，行程里的每个景点都标注攻略出处。零件没有超纲的，项目没有虚架子。把 `source/` 目录克隆下来，每一篇的示例和案例都能直接跑，祝折腾愉快。

## 源码

[https://github.com/yidao620c/xneng-langchain-langgraph](https://github.com/yidao620c/xneng-langchain-langgraph)

## 参考

- [LangChain 官方文档 - Agents](https://docs.langchain.com/oss/python/langchain/agents)
- [LangChain 官方文档 - Tools](https://docs.langchain.com/oss/python/langchain/tools)
- [LangGraph 官方文档 - Memory / Store](https://docs.langchain.com/oss/python/langgraph/memory)
- [LangChain 官方文档 - Chat models](https://docs.langchain.com/oss/python/langchain/chat-models)


---

> 作者: XiongNeng  
> URL: https://xiongneng.me/posts/ai/langchain/langchain-langgraph-12-travel-assistant/  

