学习进度
最后一篇,把整套技术串成一个能出门用的东西,个人旅游助手。你说一句「十一去杭州玩两天,不吃辣,喜欢博物馆」,它查天气、翻攻略、结合你的偏好排出行程,存成 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 进长期记忆。写文件让产出可留存可分享,留档让下次对话能引用上次的结果。这一步要做成工具交给模型调用,什么时候存、存什么版本,是模型根据对话进度判断的,代码预先写死反而接不住对话的变数。
核心用法
五个工具和一个组装函数,逐个过。
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
参考