使用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 讲清楚返回什么。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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)
1
2
第一天:多云,22℃,东南风 2 级
第二天:小雨,19℃,东风 3 级

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@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。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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)
    ...
1
2
3
饮食=不吃辣
兴趣=博物馆与安静的地方
预算=两千

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

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

1
uv run python travel.py mems
1
2
3
4
饮食=不吃辣
兴趣=博物馆与安静的地方
预算=两千
last_plan_杭州 -> # 杭州两日行程(十一期间)偏好:不吃辣 · 喜欢...

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

3. 行程落盘工具

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

1
2
3
4
5
6
7
8
@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 一起传进去,一个函数装完。

1
2
3
4
5
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 演示用四步脚本,先查偏好、再查天气、再查景点、最后存行程,每一步都是模型该有的动作,工具全部真执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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="杭州两日行程已生成并存档。"),
    ]))
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
协作轨迹:
  [HumanMessage] 十一去杭州玩两天
  [AIMessage] 调用 recall_preferences({})
      = (还没有任何偏好记录)
  [AIMessage] 调用 get_weather({'city': '杭州'})
      = 第一天:多云,22℃ / 第二天:小雨,19℃
  [AIMessage] 调用 search_attractions({'city': '杭州'})
      = 西湖(山水)/ 灵隐寺(人文)/ 丝绸博物馆(室内)
  [AIMessage] 调用 save_itinerary({...})
      = 行程已保存到 杭州.md,并在长期记忆留档
(共 4 轮工具调用)

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

完整案例

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

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

带 –trace 跑,第一轮就看出门道。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
协作轨迹:
  [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 在这里真实兑现。第二轮补一个山水标签查西湖,因为它预判行程需要户外选项;第三轮存行程。三次分组,每次都只发该发的调用,多工具协作的精髓是按需,不是每轮全查一遍。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
(共 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 元,在两千以内。

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

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

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

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

1
2
3
4
5
6
7
# 杭州两日行程(十一期间)

偏好:不吃辣 · 喜欢博物馆与安静的地方 · 预算 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

参考