Agent 学习笔记
通过调试一个带工具调用的 Agent,记录 Agent 的核心机制、工具选择原理、会话记忆行为与调试方法。结论均来自实际实验,场景已抽象为通用描述,可迁移到任意 LangChain/LangGraph Agent 项目。
一、一个 Agent 通常有两类实现
| 高层封装型 | 底层手搓型 | |
|---|---|---|
| 编排方式 | 框架的工厂函数(如 create_agent) |
状态图(如 StateGraph) |
| 范式 | ReAct(思考→调工具→看结果→再思考) | Plan-Execute-Replan(规划→执行→重规划) |
| 提示工程 | 薄(一句系统提示) | 厚(每个节点各有 prompt + 输出模板 + 动态注入) |
| 代码量 | 少 | 多 |
| 灵活度 | 低(框架包办循环) | 高(每步可控) |
| 适合场景 | 简单问答 | 多步骤、要可控、要结构化产出 |
设计原则:简单任务用高层封装,复杂任务用底层手搓。
二、Agent 的三层结构
一个 Agent 的构造通常有三个核心参数,对应三层:
model → 大脑(思考、决定调不调工具、生成回答)
tools → 手脚(实际执行操作,靠描述被 LLM 识别)
checkpointer → 记忆(按会话 ID 存取对话历史)
- 大脑:任一支持 function calling 的 LLM(OpenAI、通义、DeepSeek 等)。模型必须支持工具调用,否则 agent 不成立。
- 手脚:工具,必须是框架的 Tool 类型(如 LangChain 的
BaseTool)才能绑定给 LLM。 - 记忆:
checkpointer,保存对话状态,让多轮对话有上下文。
三、ReAct 与 RAG 的关系(易混淆)
这俩是不同维度的概念:
- ReAct:Agent 的运行范式——思考→调工具→看结果→再思考→回答的循环。
- RAG:一种能力——检索知识库文档喂给 LLM,体现为一个「检索工具」。
一个 agent 可以是「用 ReAct 范式实现,其中挂了一个 RAG 检索工具」。两者不冲突:ReAct 是骨架,RAG 是其中一个工具的能力。
举例:一个 agent 有两个工具——「查当前时间」和「检索文档」。问时间时走 ReAct 但不走 RAG;问文档内容时既走 ReAct(作为工具被调)又走 RAG(内部检索)。
四、高层封装与底层引擎的关系
不是「高层型用封装库、底层型用图库」——两者底层都是同一个图引擎。
验证:高层工厂函数(如 create_agent)返回的对象类型是 CompiledStateGraph(图引擎的编译图)。
高层 API(create_agent、tools、messages)
│ 底层基于
▼
图引擎(StateGraph、节点、边、checkpointer)
- 高层工厂函数是「快捷方式」,内部调图引擎自动搭好 ReAct 图(agent 节点 + tools 节点 + 循环边)。
- 底层手搓不走快捷方式,直接用图引擎定义节点和边,更灵活、代码更多。
五、工具系统
5.1 工具装饰器
from langchain_core.tools import tool
@tool
def get_weather(city: str = "Beijing") -> str:
"""查询指定城市的实时天气。当用户问"天气/下雨/温度"时使用。"""
...
装饰器把普通函数变成工具。LLM 收到的是工具的 schema:
name: get_weather
description: 查询指定城市的实时天气... ← 来自 docstring
args: { city: string, 默认 "Beijing" }
5.2 docstring 不是注释(关键区分)
| 写法 | 运行时存在 | 谁能看 | |
|---|---|---|---|
| 注释 | # xxx |
否,编译时丢弃 | 只有人 |
| docstring | """xxx"""(紧跟 def) |
是,存到 __doc__ |
程序/LLM |
验证:工具对象的 .description 属性内容就是原始 docstring。装饰器读取 docstring → 存到 .description → 发给 LLM。
所以工具的 docstring 不是写给人看的注释,而是「教 LLM 什么时候该用这个工具」的说明书。
5.3 工具不一定要用框架的装饰器
核心要求是「变成框架的 Tool 类型」,途径有多种:
| 方式 | 场景 |
|---|---|
装饰器(@tool) |
本地简单工具 |
| 继承 Tool 基类 | 复杂工具,需要内部状态 |
from_function |
精细控制 schema |
| MCP 工具 + 加载器 | 远程工具,adapter 自动转换 |
MCP 工具可以用任何框架写(不一定是 LangChain),通过 adapter 转成框架的 Tool 类型后也能绑定给 LLM。这体现了 MCP 的价值:工具实现与 Agent 框架解耦。
六、工具选择机制(实验深挖)
这是最有价值的一组实验。目标是验证「LLM 凭什么选某个工具」。
实验设定
一个 agent 有两个工具:
- 工具 T(被测工具):原本是「查时间」,schema 清晰。
- 工具 R:检索文档,描述明确「查文档」。
逐步把工具 T 的三件套(name / description / args)改模糊,问一个需要 T 的问题,观察 LLM 是否还调。
实验过程
| 改动 | name | description | args | LLM 调 T? |
|---|---|---|---|---|
| 原始 | get_time | 查时间... | timezone | 调 |
| 改 description | get_time | 处理数据 | timezone | 调 |
| 改 name | process_data | 处理数据 | timezone | 调 |
| 改 args | process_data | 处理数据 | region | 调 |
| 注释掉工具 | — | — | — | 不调,说不知道 |
三件套全模糊后还调,出乎意料。逐步排查原因。
第一次错误归因:以为靠命名识别
被早期一次不相关的环境问题(localhost 走 IPv6 导致连接失败)误导,一度归因为库版本不兼容,后证明与工具选择无关。
第二次错误归因:以为靠工具 schema 精确匹配
改三件套后仍调,开始查对话历史。
真相一:对话历史是强信号
查持久化存储里的历史 messages,发现三件套模糊时 LLM 调用传的参数是旧参数名,而当前 schema 已改新名。
历史记录:
用户: 现在几点
AI: tool_calls=[(工具名, {旧参数名: ...})] ← 抄历史参数名
结论:LLM 从对话历史抄了参数名,历史盖过当前 schema。
真相二:清历史后仍调——排除法 + 试探
清空存储 + 用全新会话 + 三件套模糊,LLM 仍调,但参数变成当前的新名(证明无历史污染)。此时工具列表只有两个:工具 R(描述明确"查文档",排除)、工具 T("处理数据"宽泛)。问题明确需要外部数据,LLM 用排除法选了唯一可能的 T 试探性调用。
最终结论
LLM 选工具不是精确的 schema 匹配,而是综合决策:
需求驱动(问题需要外部数据)
+ 能力推断(看工具 description 推断能力)
+ 排除法(排除明显不匹配的)
+ 试探(剩下最可能的就试调)
+ 对话历史(强信号,能盖过当前 schema)
要让 LLM 真的不调,描述要「明确不相关」(如"发送邮件"),而不是「模糊宽泛」(如"处理数据"——"数据"范围太广,反而成兜底选项)。
附加观察
问一个能从已有信息推导的衍生问题时(如已知 A 地时间,问 B 地时间),LLM 没调工具,直接换算。说明 LLM 知道「已有数据够用时就不调工具」,对调用时机有判断。
七、会话记忆与持久化
7.1 checkpointer 机制
checkpointer 按会话 ID 存取对话状态。同一会话 ID 的多次调用会自动加载历史 messages,LLM 据此有上下文。
config={"configurable": {"thread_id": session_id}} # 会话 ID 即记忆钥匙
7.2 持久化存储
记忆可以存在内存(重启丢失)或持久化存储(如 SQLite 文件、Redis):
- 持久化的好处:重启不丢会话,可多实例共享
- 持久化的代价:调试时历史会污染实验
7.3 踩坑:同步 checkpointer 不能用于 async agent
同步版 checkpointer(如 SqliteSaver)的 async 方法直接抛 NotImplementedError。而 agent 的 ainvoke 走 async 路径会调它。内存版(MemorySaver)之所以能用是因为同时实现了 sync+async,SQLite 同步版没有。
必须用 async 版(如 AsyncSqliteSaver,基于 aiosqlite)。涉及改动:
- 构造时不再创建 checkpointer,改为
async setup()延迟初始化 - 读写历史的方法改 async(用
aget_tuple/adelete_thread) - app 启动时
await setup(),关闭时await close()
7.4 调试要点
重启服务 ≠ 清空状态。只要有持久化,状态跨重启存活。干净实验必须显式清状态:
- 删存储文件(注意 WAL 模式有
-wal/-shm附属文件) - 或调清空接口
- 或换新会话 ID
八、RAG 路径与 embedding 触发
8.1 两条工具路径
问实时信息(如时间) → LLM 选实时工具 → 不走 RAG
问知识库内容 → LLM 选检索工具 → 走 RAG → 触发 embedding
检索工具的 docstring 决定何时被选。只有问题匹配描述时 LLM 才选它。
8.2 embedding 调用位置
embedding 服务通常有两个方法:
embed_query:检索时把查询向量化embed_documents:入库时把文档向量化
完整 RAG 链路:
检索工具(query)
→ 向量库.as_retriever().invoke(query)
→ embed_query(query) ← 查询向量化
→ 向量库检索相似文档
→ 返回文档片段给 LLM
要断点 embedding:上传文档触发 embed_documents,或问知识库内容触发 embed_query。
九、调试方法
9.1 VSCode 断点调试
配置 .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [{
"name": "Debug Agent",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/入口文件.py",
"console": "integratedTerminal",
"justMyCode": true,
"python": "${workspaceFolder}/.venv/Scripts/python.exe",
"env": { "PYTHONPATH": "${workspaceFolder}" },
"cwd": "${workspaceFolder}"
}]
}
- 打断点:点行号左侧 gutter 出红点(与 PyCharm 相同)
- F5 启动,F10 单步,F5 继续
- 断点停下后用「调试控制台」(Ctrl+Shift+Y)求值表达式,如
result["messages"]、[type(m).__name__ for m in result["messages"]]
9.2 print 验证工具调用
在工具函数内加 print,能直接确认工具是否被调用、参数是什么。比看日志更直观,是验证「LLM 到底调没调这个工具」的最简手段。
9.3 查 checkpoint 历史
import asyncio, aiosqlite
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
async def main():
conn = await aiosqlite.connect('checkpoint.db')
saver = AsyncSqliteSaver(conn)
tup = await saver.aget_tuple({'configurable': {'thread_id': 'xxx'}})
msgs = tup.checkpoint['channel_values']['messages']
for m in msgs:
print(type(m).__name__, getattr(m, 'tool_calls', None))
await conn.close()
asyncio.run(main())
能看到完整的 ReAct 痕迹(HumanMessage → AIMessage(tool_calls) → ToolMessage → AIMessage)。
十、关键认知清单
- Agent = 大脑(model)+ 手脚(tools)+ 记忆(checkpointer),三者缺一不可。
- LLM 不知道任何实时信息(时间、监控、日志、天气),工具是它感知世界的眼睛和手。没有工具,LLM 再强也只能说"我不知道"。
- 工具的 docstring 是给 LLM 看的说明书,不是注释。name + description + args 三件套共同决定 LLM 是否选用。
- LLM 选工具不是精确匹配,是需求驱动 + 排除法 + 试探 + 历史的综合决策。描述要「明确不相关」才能阻止调用,「模糊宽泛」反而成兜底。
- 对话历史是强信号,能盖过当前工具 schema,甚至抄历史里的参数名。调试必须隔离历史。
- 持久化让状态跨重启存活,重启服务 ≠ 清状态。SQLite 是零部署的会话存储选择。
- 同步 checkpointer 不能直接用于 async agent,必须用 async 版,否则
aget_tuple抛 NotImplementedError。 - 高层封装底层也是图引擎,工厂函数返回的就是编译后的图。
- RAG 和 ReAct 不冲突:ReAct 是运行范式,RAG 是工具能力。
- 提示工程的投入决定 Agent 可控性:提示薄则行为易被 tools/历史改变,提示厚则稳定可控。
- 调试 Agent 要隔离变量:清历史、换会话、看 checkpoint、print 验证,避免被历史/框架默认行为误导。
评论区