LangChain create_agent 实战

QuibblerAgentQuibblerAgent 2026-11-06 约 21 分钟 1 次阅读

第 4 篇收尾留了张欠条:手动执行循环是 agent 的骨架,但每个应用重抄一遍并不体面,create_agent 会把它收编成一行;第 5 篇讲完链式编排,又把话头递到了 agent 门前。本篇是《LangChain 从入门到实战》第 6 篇,主角就是 create_agent:先把第 4 篇的 while 循环折算成一行调用,再用 model、tools、system_prompt 三个参数搭起最小代理,重点拆 checkpointer 配 thread_id 的多轮记忆,然后说清 result 怎么读、stream 怎么调试,最后以适用边界收束——它不够用时,路通向哪里。

1、从手动循环到一行成品

先定口径:create_agent 由主包提供(当前 1.4.x,见 PyPI 主页),从 langchain.agents 导入;返回值不是函数,而是一张编译好的 LangGraph 图,安装主包时 langgraph 会作为依赖一并装上,行为细节以 官方 Agents 文档为准。

老教程里的 create_react_agent 住在 langgraph.prebuilt,1.0 起推荐入口收拢到主包的 create_agent——见到旧导入,直接按新入口改写。

第 4 篇用 14 行 while 循环拼出“提议、执行、回填”的闭环;get_weather 工具与 model 原样搬来,同一件事的成品写法如下:

from langchain.agents import create_agent

agent = create_agent(model, [get_weather])   # model 沿用第 4 篇
result = agent.invoke(
    {"messages": [{"role": "user", "content": "杭州今天多少度?"}]})
print(result["messages"][-1].content)

解读:循环没有消失,只是搬进了库函数——create_agent 内部用 LangGraph 搭了一张“模型节点加工具节点”的双节点循环图:模型节点的输出带 tool_calls 就路由去工具节点,执行完回到模型节点,直到不再发起调用。手动循环里的每件活在成品里的新去处如下:

手动循环里的活 成品里由谁干
tool_map 建索引与分发 工具节点按注册名分发
tool_calls 判断终止 条件边路由到结束
messages 手动追加 状态字段自动累积
最大轮数自己补 recursion_limit 步数上限

第 4 篇练习 2 让你给 while 循环手补轮数上限,成品的兜底是 recursion_limit——超限抛 GraphRecursionError,1.0.6 起默认 1000,工具循环不会真的无限转。输入形态顺带看清:invoke 吃的是初始 State,messages 键装消息列表,{"role": "user", ...} 形式的字典会被规范成 HumanMessage。

2、最小代理与系统提示

三个参数撑起九成场景。model 接受 init_chat_model 初始化的实例,也接受 "openai:gpt-6.1-terra" 这样的字符串简写;tools 是 @tool 工具列表,第 4 篇的装饰器语法原样适用;system_prompt 给整段会话定人设与规矩,接受字符串、SystemMessage 或接收状态的函数。完整示例如下:

from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """查询指定城市实时天气,返回天气现象与摄氏温度。"""
    data = {"杭州": "晴,22 度", "北京": "多云,18 度"}
    return data.get(city, "暂无该城市数据")

model = init_chat_model("openai:gpt-6.1-terra", temperature=0)
agent = create_agent(
    model,
    [get_weather],
    system_prompt="你是天气播报员:查到数据用一句话播报,查不到就明说,不要编造。",
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": "杭州今天天气如何?"}]})
print(result["messages"][-1].content)

一次典型运行,print 出的是“杭州今天晴,气温 22 摄氏度”。解读三点。模型第一步发起 get_weather 调用、工具节点执行回填、模型第二步产出播报,两步全部发生在 invoke 内部,调用方只看到最终回答。系统提示里“查不到就明说”是给循环上的第一道护栏:工具回填“暂无该城市数据”后,模型照规矩承认,而不是硬编一条。第 4 篇说工具描述决定调用质量,系统提示则决定拿到结果后怎么用——两层提示词一个管路由、一个管收尾,要配合着写。

3、给 agent 接上记忆

手动循环还有个没解决的缺口:每次调用 messages 都从零开始,第二轮对话要自己把历史搬进列表。create_agent 默认同样如此——没有 checkpointer 的 agent 每次调用都失忆。失忆现场如下:

agent = create_agent(model, [get_weather],
                     system_prompt="你是天气播报员。")
r1 = agent.invoke({"messages": [
    {"role": "user", "content": "我叫阿策,请记住。"}]})
r2 = agent.invoke({"messages": [
    {"role": "user", "content": "我叫什么名字?"}]})
print(r2["messages"][-1].content)   # 抱歉,我不知道你的名字

两次 invoke 互不相干:名字躺在 r1 的历史里,r2 的 messages 只有一条新问题。接上记忆只需两处改动:

from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(model, [get_weather],
                     system_prompt="你是天气播报员。",
                     checkpointer=InMemorySaver())      # 改动一:存档器
config = {"configurable": {"thread_id": "user-001"}}   # 改动二:线程号

r1 = agent.invoke({"messages": [
    {"role": "user", "content": "我叫阿策,请记住。"}]}, config)
r2 = agent.invoke({"messages": [
    {"role": "user", "content": "我叫什么名字?"}]}, config)
print(r2["messages"][-1].content)   # 你叫阿策

解读四点。checkpointer 是检查点存档器,agent 每步跑完把状态落档,下次调用先取回再继续。thread_id 标识一段会话,官方口径是用 thread_id 持久化会话历史,前提是配好 checkpointer——两个参数配套出现,缺一个记忆都不成立。同一 thread_id 复用同一份历史,换个值就是全新对话,多用户隔离靠的就是这个键。InMemorySaver 只存进程内存,重启即失,生产要换 SqliteSaver、PostgresSaver 这类落盘实现,见 持久化文档。记忆的流转如下图:

flowchart LR A[第一轮提问] --> B[运行产生历史] B --> C[按线程号存档] C --> D[第二轮提问] D --> E[同线程号取回] E --> F[历史加新问进模型]

图后一句解读:存档的不是摘要而是完整消息列表,第 4 节读 result 时能亲眼看到两个轮次的全部消息;至于长对话的压缩与跨线程的长期记忆,是下一篇记忆专篇的课题。

4、result 的正确读法

result 是跑完一轮的最终 State,核心键就是 messages——带 checkpointer 时装的是整个线程的累计历史,不带时是本次调用的历史。一次“查天气”的典型轨迹如下:

顺序 消息类型 承载内容
1 HumanMessage 用户问题
2 AIMessage tool_calls 调用请求
3 ToolMessage 工具执行结果回填
4 AIMessage 最终回答

两条取值要领:最终回答取最后一条消息的 content,即 result["messages"][-1].content,取 [0] 拿到的是用户自己的问题;排查问题要看中间轨迹,遍历写法如下:

for msg in result["messages"]:
    name = type(msg).__name__
    calls = getattr(msg, "tool_calls", None)
    if calls:                              # AIMessage 发起调用
        for tc in calls:
            print(name, "调用", tc["name"], tc["args"])
    elif name == "ToolMessage":            # 工具结果回填
        print(name, "结果", msg.content)
    else:                                  # 问题与最终回答
        print(name, msg.content)

一次典型运行的输出如下:

HumanMessage 杭州今天天气如何?
AIMessage 调用 get_weather {'city': '杭州'}
ToolMessage 结果 杭州:晴,22 度
AIMessage 杭州今天晴,气温 22 摄氏度。

解读:这段遍历把第 4 篇的 tool_calls 字段与 ToolMessage 回填串成了完整时间线——agent 没有引入新消息类型,用的全是前几篇见过的消息对象,抽象换的是流水线、不换零件。带 checkpointer 后列表会越积越长,第二轮 invoke 的 messages 里第一轮的问题与回答都在场:记忆不是魔法,就是把这份列表原样再喂给模型,上下文窗口与 token 成本的账留到记忆专篇细算。

5、用 stream 看执行轨迹

invoke 只给终态,过程是黑盒;调试时想知道模型到底调了几次工具、参数是什么,用 stream 的 updates 模式——它在每个节点跑完吐一次状态增量:

config = {"configurable": {"thread_id": "debug-1"}}
for chunk in agent.stream(
    {"messages": [{"role": "user", "content": "杭州今天多少度?"}]},
    config,
    stream_mode="updates",
):
    for node, update in chunk.items():     # 节点名到状态增量
        msg = update["messages"][-1]
        calls = getattr(msg, "tool_calls", None)
        print(f"[{node}] {type(msg).__name__} {calls or msg.content}")

一次典型运行的输出如下:

[model] AIMessage [{'name': 'get_weather', 'args': {'city': '杭州'}, ...}]
[tools] ToolMessage 杭州:晴,22 度
[model] AIMessage 杭州今天晴,气温 22 摄氏度。

解读四点。chunk 的形状是“节点名到该节点状态增量”的字典,节点名固定为 model 与 tools——它们是内部图的注册名。从段数能直接读出工具链长度:两段 model 中间夹一段 tools 是一次“调用、回填、作答”,第 4 篇的两步工具链在这里会打出五段。updates 看的是步进,要做逐 token 的打字机效果换 stream_mode="messages",两种模式的差异见 流式文档。调试轮次同样会写进记忆,干净的做法是像示例那样单开一个 thread_id。

6、create_agent 的边界

成品代理的形状是固定的:一个模型、一组工具、一张双节点循环图。官方留了 state_schema 参数(传入 AgentState 的子类)往状态里加自定义字段,但图的拓扑改不了。三类需求的对比如下:

需求形态 顺手工具 说明
固定管道跑一遍 LCEL 链 第 5 篇,无循环三行成型
单 agent 自主工具循环 create_agent 本篇主角
定制流程与多 agent 协作 LangGraph 手搓图,控制权全在手里

展开第三行:要在工具循环外加固定工序(先检索再汇总的强制顺序)、要多个 agent 分工协作、要在关键动作前插入人工审批的中断、要节点级重试与并行——都得自己搭图。而搭图的语法正是 create_agent 内部在用的那套:模型节点、工具节点、条件边、回边。本站后续的《LangGraph 从入门到实战》(Agent 专题收官系列)专讲这件事,create_agent 内部那张图会在其中一篇被逐行拆开——学完那一篇,这个一行成品对你不再有黑盒。回收第 5 篇的口诀再补半句:链是声明出来的,agent 是循环出来的,循环用成品,图自己搭。

7、实战带记忆的调研助手

把三件能力拼起来:两个工具加 checkpointer 记忆加多轮追问。场景是内部文档调研——一个工具检索文档、一个工具记笔记,助手跨轮次记住调研上下文。工具定义如下:

from langchain_core.tools import tool

notes = []                                # 笔记本,进程内共享

@tool
def search_docs(keyword: str) -> str:
    """按关键词检索内部技术文档,返回标题与摘要。"""
    docs = {
        "记忆": "《会话记忆设计》:用 checkpointer 按线程存取完整历史。",
        "工具": "《工具描述指南》:描述要写清做什么、输入输出与边界。",
    }
    return docs.get(keyword, "未检索到相关文档")

@tool
def save_note(text: str) -> str:
    """把一条调研要点写入笔记,返回确认信息。"""
    notes.append(text)
    return f"已记录(第 {len(notes)} 条)"

组装 agent 并连问两轮:

researcher = create_agent(
    model,
    [search_docs, save_note],
    system_prompt="你是调研助手:先检索文档,再把要点整理成一句话"
                  "存入笔记,最后答复用户。",
    checkpointer=InMemorySaver(),
)
config = {"configurable": {"thread_id": "research-1"}}

for q in ("调研一下 agent 的记忆怎么做,检索并记笔记。",
          "刚才记的第 1 条笔记原文是什么?"):
    r = researcher.invoke(
        {"messages": [{"role": "user", "content": q}]}, config)
    print("问:", q)
    print("答:", r["messages"][-1].content, "\n")

一次典型运行的输出如下:

问:调研一下 agent 的记忆怎么做,检索并记笔记。
答:已检索到《会话记忆设计》:用 checkpointer 按线程存取完整历史;要点已记为第 1 条笔记。

问:刚才记的第 1 条笔记原文是什么?
答:第 1 条笔记是:"用 checkpointer 按线程存取完整历史。"

解读三点。第一轮是标准工具循环:模型调 search_docs、再调 save_note、最后作答,全程一次 invoke。第二轮没有触发任何工具调用——“刚才记的笔记”在线程历史里,模型直接从第一轮的回答取材作答,这正是 checkpointer 在工作;把 thread_id 换个值再问第二句,它立刻失忆。notes 列表在进程内跨轮共享,但那是 Python 变量的作用域,与 agent 记忆是两回事——进程重启后 notes 与 InMemorySaver 一起清零,生产落地要各换各的持久化方案。

8、总结与练习

回顾全文:create_agent 一行收编第 4 篇的手动循环,内部是 LangGraph 的双节点循环图;model、tools、system_prompt 三参数撑起九成场景;checkpointer 配 thread_id 让 agent 跨调用记住历史,同一线程号复用同一份历史;result["messages"] 是全轨迹,最终回答取最后一条的 content;stream 的 updates 模式按节点吐增量,是数工具链长度的调试利器;固定管道归 LCEL,定制图归 LangGraph。对于手头的应用,建议把第 4 篇的手动循环助手换成 create_agent 版:先裸跑通工具循环,再补 checkpointer 记忆,最后用 stream 把轨迹打一遍——三步走完,成品代理对你就是透明的。下一篇是记忆专篇:checkpointer 存的是完整历史,长对话的压缩与跨线程的长期记忆在那里展开。

练习两道:

  • 把第 7 节的第二问换一个全新 thread_id 再发,观察失忆;切回原线程复问,确认历史还在
  • 把第 4 篇的天气加计算器交给 create_agent,用 updates 模式数一数两步工具链共打出几段更新

相关推荐

获取系统SDK版本、判断手机ROM
精选Code

获取系统SDK版本、判断手机ROM

Build获取系统SDK版本Android中部分API的使用,需要在特定的SDK版本之后才能使用,因此在兼容老版本SDK的时候,经常需要判断API的版本。各种Android版本的对应关系参考《Android各版本对应的SDK版本》判断手机ROM有时候需要判断手机系统的ROM,检测ROM是MIUI、EMUI还是Flyme,可以使用getprop命令,去系统build.prop文件(关于build.p

4.2k
AndroidStudio中各种中文乱码问题
精选Code

AndroidStudio中各种中文乱码问题

AndroidStudio中各种中文乱码问题1、编译Java错误信息乱码在出现这个Annotation processors must be explicitly declared now...问题的时候,正好也发现AndroidStudio Build Output错误信息都乱码。经常遇到各种问题,习惯了,现在遇到的问题,都是以后的答案。 1.1、修改项目build.gradle(无效)在整个p

2.8k
PydanticAI 详解:类型安全的 Python AI SDK
精选AI

PydanticAI 详解:类型安全的 Python AI SDK

PydanticAI 详解:类型安全的 Python AI SDK写 LLM 应用最疼的两件事:模型返回的 JSON 不合预期、换一家模型商要改一串代码。pydantic/pydantic-ai(PydanticAI)用 Pydantic 团队的老本行回答了第一件——输出用 BaseModel 定义、运行前先校验,错误从运行时提前到编码时;第二件则用"模型即字符串"回答:换个厂商只改一个参数。如今

83