LangChain Tools 与 Agent 实战

把一个普通 Python 函数变成模型认得出、挑得中、填得对的工具,再用 create_agent 把工具箱交给店长,让它自己决定先做哪件、后做哪件。

30″30 秒看懂 Tools 与 Agent 实战

还是那间中央厨房。厨师(大模型)手艺再好也只会做菜——他跑不出去买货、算不了账、更下不了单。于是厨房里设了一个店长(Agent),店长自己也不做菜,他手上有一个工具箱:查库存的放大镜、算价钱的计算器、下订单的钢笔。

关键在工具箱的细节:每件工具上都挂着一张说明卡——这件家伙什叫什么、什么时候该拿起来、要填哪几个空。店长不会拆开工具研究内部构造,他只读说明卡。卡写得含糊,工具就一直躺在箱子里;卡写错了,他就会在该拿计算器的时候抓起钢笔。

图① 30 秒看懂:给店长配一套挂着说明卡的工具箱
图① 30 秒看懂:给店长配一套挂着说明卡的工具箱
比喻里的角色对应的技术概念它到底干了什么
厨师大模型真正产出内容的人;也是判断「该用哪件工具、参数填什么」的那个脑子
店长Agent拿着工具箱反复决策:还要不要再做一件事、做哪件,做完了就收工
工具箱tools=[...]交给 Agent 的工具列表,内置的和你自己写的混在一起,Agent 看不出区别
一件工具Tool一个被包装过的普通函数:有名字、有说明、有参数表、有真身
挂在工具上的说明卡description模型挑不挑这件工具,全看这张卡;它是这一讲的主角
卡上的填空格args_schema每个参数叫什么、什么类型、什么含义、有没有取值范围
工具的金属部分被装饰的那个函数真正跑起来的代码,由你的进程执行,模型碰不到
店长的工作循环create_agent 的执行循环调模型 → 看要不要用工具 → 用 → 把结果塞回去 → 再调模型
墙上的备忘板checkpointer + thread_id让这一轮记得上一轮说过什么,不同会话各用各的板子
⛔ 整讲只有一条铁律 工具的 description 是写给模型看的,不是写给同事看的注释。模型选不选你这个工具,全看它。函数写得再漂亮、逻辑再严密,说明卡含糊,它就永远不会被拿起来。
这一讲和前后几讲的分工 「模型怎么把要办的事写成一张结构化委托单」是协议层的事,在 Function Call 那一讲讲透了;「Agent 为什么能自主、ReAct 循环长什么样、它和一次性委托的边界在哪」在 Agent 的原理 那一讲。这一讲不重讲原理,只干一件事:把工具真正做出来,把 Agent 真正跑起来

01概念:Tool 到底是什么

一个被包装过的普通函数、它的四个要素、现成的与自己写的、以及和 Function Call 的分工

1.1 Tool 是一个「带说明书的函数」

只会生成文本的模型,能力是封闭的:它答不出今天的库存,也算不准一笔带折扣的总价,更不可能替你在数据库里插一条订单。Tool 就是为了把这堵墙凿开——它把外部系统、API 或者你自己的一段 Python 函数封装成一个可调用模块,让模型能够跟真实世界互动。

但要注意 Tool 的本体有多朴素:它就是一个普通函数,外面裹了一层说明书。裹上这层之后多了三样东西:

01一个身份

有了唯一的 name,模型点名的时候才知道点谁。同一个工具箱里名字不能重复。

02一份说明

有了 description 和参数表,这些会被送进模型的上下文,成为它选择的依据。

03一套统一接口

成了 Runnable:invoke / ainvoke 一视同仁,所以它能被塞进链,也能被塞进 Agent。

模块化是 Tool 的设计前提:一个工具只专注一件事,搜索工具、计算工具、天气工具各管一摊,需要谁就把谁放进列表。这也是为什么后面案例里的三个工具,一个只查库存、一个只算钱、一个只下单——把它们合成一个「万能工具」,模型反而不知道什么时候该用它。

1.2 四要素:模型看三样,你的程序跑一样

一件完整的 Tool 由四个要素组成,这是这一讲最该背下来的结构:

要素对应的字段谁在用它
① 名称name模型:用来点名。你:用来在日志里认出是哪件工具被调用了
② 功能描述description模型:唯一的选择依据。写不清楚,工具形同虚设
③ 参数结构args_schema(一个 Pydantic 模型)模型:照着填参数。框架:在执行前替你做一次校验
④ 要调用的函数被装饰的那个函数本体只有你的程序在跑它。模型自始至终没碰过这行代码

还有一个只对 Agent 生效的开关 return_direct:默认 False,工具结果会回到模型手里、让它决定下一步;设成 True 则执行完直接把结果甩给用户、循环当场中断。

三步流程,一句话说完 第一步,把 ①②③ 作为上下文交给模型;第二步,模型据此推断该调哪些工具、参数填什么;第三步,触发 ④ 真正执行。精心挑选的名称、描述和参数结构会直接提升模型的表现——这不是玄学,是因为模型手上只有这三样。

1.3 现成的工具与自己写的工具

社区已经有大量现成工具:搜索引擎、维基百科、文件操作、Shell、SQL 查询、各种云服务 SDK,装上对应的集成包就能直接塞进 tools 列表。要不要自己写,判断标准很简单:

这件事的性质怎么选例子
任何人做都一样用现成的联网搜索、抓网页、读本地文件、跑一段计算
只有你们公司这么做自己写查你们的库存表、按你们的规则算价、走你们的审批下单
现成的能用但字段不对包一层StructuredTool 把已有函数裹起来,重写名称与描述
builtin_tools.py —— 现成工具与自定义工具混装在同一个列表里
"""内置工具与自定义工具的分界线在哪。

社区里已经有大量现成工具(搜索、维基、文件操作、Shell、SQL……),
装上对应的集成包就能直接塞进 tools 列表,不用自己写。

判断标准很简单:
    这件事任何人做都一样      → 用现成的(搜索、爬网页、读文件)
    这件事只有你们公司这么做  → 自己写(查你们的库、算你们的价、下你们的单)
"""

import os

from langchain.tools import tool


def load_search_tool():
    """搜索类工具的典型接法:装包 → 配 key → 实例化 → 塞进 tools。

    pip install langchain-tavily
    export TAVILY_API_KEY=...
    """
    if not os.environ.get("TAVILY_API_KEY"):
        return None
    from langchain_tavily import TavilySearch

    return TavilySearch(max_results=3)


@tool
def get_internal_headcount(dept: str) -> str:
    """查询公司某个部门的在编人数。仅限内部部门名,例如 研发部、售后部。"""
    table = {"研发部": 128, "售后部": 46, "市场部": 31}
    if dept not in table:
        return f"没有 {dept} 这个部门"
    return f"{dept} 在编 {table[dept]} 人"


def build_toolbox() -> list:
    """把现成工具与自定义工具混在一个列表里——对 Agent 来说它们没有区别。"""
    box = [get_internal_headcount]
    search = load_search_tool()
    if search is not None:
        box.insert(0, search)
    else:
        print("未配置 TAVILY_API_KEY,这次只带自定义工具")
    return box


if __name__ == "__main__":
    tools = build_toolbox()
    for t in tools:
        print("-", t.name, "|", t.description.splitlines()[0][:50])

    # 工具箱不是越大越好:
    # 工具越多,每次调模型的上下文越长、选错的概率也越高。
    # 超过十来个就该按场景分组,或者用动态工具选择只挂当前用得上的那几个。
    print("当前工具数:", len(tools))
工具箱不是越大越好 工具越多,每次调模型时上下文里塞的说明书越长,token 成本上升、选错的概率也上升。超过十来件就该按场景分组,或者用动态工具选择,只把当前用得上的那几件挂出去。

1.4 和 Function Call、和 Agent 原理的分工

这三件事很容易糊成一团,其实是三个层次:

层次在哪一讲关心什么
协议层Function Call模型怎么把「要办哪件事、参数填什么」写成一张结构化委托单,消息里怎么一来一回
机制层Agent 的原理为什么加一层循环就有了自主性,ReAct 怎么想、怎么做、怎么看
工程层这一讲工具怎么定义才被选中,Agent 怎么装配、怎么观察、怎么限住、怎么记住

换句话说:Function Call 那一讲教你手写「调用—执行—回填」的整套循环;这一讲是把那套循环交给框架,你只负责把工具做好、把护栏立住。手写一遍是必要的——不然你不知道 create_agent 内部在替你做什么;但真做项目时,没人会再手写第二遍。

02原理:四要素怎么变成模型的选择依据

说明书是怎么送进上下文的、description 该怎么写、参数 schema 管什么、循环由谁在转、三代写法怎么搬

2.1 你写的四样东西,模型只收得到三样

把一个函数变成 Tool,写法上只是加一行装饰器;但发生的事情是:①②③ 被序列化后塞进了模型的请求,④ 留在你的进程里。

图② 一件 Tool 的四要素:名称 / 功能描述 / 参数结构 / 执行的函数
图② 一件 Tool 的四要素:名称 / 功能描述 / 参数结构 / 执行的函数

先把四要素亲手打印一遍,看清楚模型收到的到底长什么样:

tool_basics.py —— 把 Tool 的四要素逐个打印出来
"""Tool 的四要素:名称、功能描述、参数结构、要调用的函数。

用 @tool 装饰器把一个普通函数升级成 Tool,然后把这四样东西
逐个打印出来看——模型收到的就是这些信息,一个字都不多。

运行:python tool_basics.py
依赖:pip install "langchain>=1.0"
"""

import json

from langchain.tools import tool


@tool
def add_number(a: int, b: int) -> int:
    """两个整数相加,返回它们的和。"""
    return a + b


# name / description / return_direct 都可以在装饰器里改写。
# 注意第一个位置参数就是工具名,传字符串即可覆盖函数名。
@tool("add_two_number", description="计算两个整数的和", return_direct=True)
def add_number_v2(a: int, b: int) -> int:
    """两个整数相加。"""
    return a + b


def show(t) -> None:
    """把一个 Tool 的四要素摊开打印。"""
    print("① 名称      name         =", t.name)
    print("② 功能描述  description  =", t.description)
    print("③ 参数结构  args         =", json.dumps(t.args, ensure_ascii=False))
    print("   完整入参 schema        =",
          json.dumps(t.args_schema.model_json_schema(), ensure_ascii=False)
          if hasattr(t.args_schema, "model_json_schema") else t.args_schema)
    print("④ 要调用的函数            =", t.func.__name__ if t.func else "(异步实现)")
    print("   return_direct         =", t.return_direct)
    print("-" * 60)


if __name__ == "__main__":
    show(add_number)
    show(add_number_v2)

    # 工具自己也是 Runnable:invoke 传一个参数字典,键名即形参名。
    print("add_number.invoke        ->", add_number.invoke({"a": 10, "b": 20}))
    print("add_two_number.invoke    ->", add_number_v2.invoke({"a": 10, "b": 20}))

    # 类型注解不是装饰用的:它决定了 args 里每个参数的 type。
    # 去掉 a: int 之后,参数类型会退化,模型更容易填错。

三个细节值得停一下:

细节说明
文档字符串就是描述不传 description 时,装饰器拿函数的文档字符串当工具描述。所以用 @tool 时文档字符串是必需的,不是可选的礼貌。
类型注解决定参数类型a: int 里的 int 会变成参数表里的 integer类型注解是必须写的——没有它,参数结构无从生成。
名称可以覆盖装饰器第一个位置参数就是工具名,@tool("add_two_number") 即可改掉函数名。名称推荐用小写词加下划线,不要带空格或特殊字符,有些模型服务会直接拒绝。

2.2 description 怎么写,模型才挑得对

⛔ 本讲铁律,换个角度再说一遍 description 不是注释。注释写给三个月后的你看,描述写给此刻正在挑工具的模型看。前者可以写「查订单」,后者写「查订单」等于没写。

一句合格的描述要回答四个问题,缺一个就会以特定的方式出错:

要回答缺了会怎样写法示例
它干什么模型完全不知道有什么用,永远不选它按订单号查询一笔订单的状态、金额与物流单号
什么时候该用该调的时候不调,直接瞎编一个答案当用户提到订单号、问「我的单子到哪了」「发货没有」时使用
参数从哪儿取参数填错,或者拿用户的原话整句塞进去order_id 是形如 SO20251208001 的订单编号
什么时候别用多工具场景里抢活,把该给别人的任务接过来用户只说了商品名而没给订单号时,先反问,不要猜
description_quality.py —— 含糊版与说清楚版,同一个问题问两遍
"""description 写得好不好,差别有多大?把两版工具摆在一起看。

同一个功能写两份描述:一份含糊、一份说清「什么时候用、输入是什么、
产出是什么、什么时候别用」。把两份分别交给同一个模型、问同一句话,
看它挑不挑得中。

运行前先配好模型访问信息(以 OpenAI 兼容网关为例):
    export OPENAI_API_KEY=...
    export OPENAI_BASE_URL=...
"""

import os

from langchain.chat_models import init_chat_model
from langchain.tools import tool


@tool("query_order", description="查订单")
def bad_version(order_id: str) -> str:
    """查订单。"""
    return "{\"order_id\": \"%s\", \"status\": \"已发货\"}" % order_id


@tool(
    "query_order",
    description=(
        "按订单号查询一笔订单的状态、金额与物流单号。"
        "当用户提到订单号、问「我的单子到哪了」「发货没有」时使用。"
        "入参 order_id 是形如 SO20251208001 的订单编号;"
        "用户只说了商品名而没给订单号时,先反问订单号,不要猜。"
    ),
)
def good_version(order_id: str) -> str:
    """按订单号查询订单状态。"""
    return "{\"order_id\": \"%s\", \"status\": \"已发货\"}" % order_id


QUESTION = "帮我看看 SO20251208001 这一单发货了吗"


def ask(tool_obj) -> None:
    """把单个工具绑定到模型上,看模型会不会主动选它。"""
    if not os.environ.get("OPENAI_API_KEY"):
        print("未配置 OPENAI_API_KEY,跳过实际请求")
        return
    model = init_chat_model("openai:gpt-4o-mini", temperature=0)
    msg = model.bind_tools([tool_obj]).invoke(QUESTION)
    if msg.tool_calls:
        print("选中工具:", msg.tool_calls[0]["name"], "参数:", msg.tool_calls[0]["args"])
    else:
        print("没有选工具,模型直接回了文本:", msg.text())


if __name__ == "__main__":
    print("【含糊版 description】", bad_version.description)
    ask(bad_version)
    print("-" * 60)
    print("【说清楚版 description】", good_version.description)
    ask(good_version)

    # 一句话记住:description 是给模型的选择依据,不是给同事的注释。
    # 它要回答四个问题:干什么、什么时候用、参数怎么填、什么时候别用。
一条很实用的经验 把工具描述当成写给一位新来的实习生的便签:他不认识你的代码、看不见你的数据库,只能靠这张便签判断该不该动手。如果这张便签他看不懂,那模型也看不懂——差别只在于他会来问你,而模型不会,它会直接猜。

2.3 参数结构:把校验挡在业务代码之前

函数签名能表达的信息很有限:参数叫什么、是什么类型,仅此而已。用 Pydantic 写一份 args_schema,你就能额外告诉模型:这个参数是什么意思、有哪些合法取值、默认值多少、上下界在哪。

tool_args_schema.py —— 用 Pydantic 给参数写说明与取值范围
"""用 Pydantic 给工具参数写一份正经的 schema。

函数签名只能表达「参数叫什么、是什么类型」,
args_schema 还能表达「这个参数是什么意思、有哪些合法取值、默认值是多少」。
这些信息会随工具一起送进模型的上下文,直接决定模型填参数的准确率。
"""

import json
from typing import Literal

from langchain.tools import tool
from pydantic import BaseModel, Field


class WeatherInput(BaseModel):
    """查询天气所需要的入参。"""

    city: str = Field(description="城市名称,使用中文,例如 北京、杭州")
    unit: Literal["摄氏度", "华氏度"] = Field(
        default="摄氏度",
        description="温度单位,默认摄氏度",
    )
    days: int = Field(
        default=1,
        ge=1,
        le=7,
        description="要查询的天数,1 表示只看今天,最多 7 天",
    )


@tool(args_schema=WeatherInput)
def get_weather(city: str, unit: str = "摄氏度", days: int = 1) -> str:
    """查询指定城市未来若干天的天气。需要实时天气、气温、下不下雨时使用。"""
    sample = {"北京": (18, 32, "晴"), "杭州": (21, 29, "多云")}
    low, high, text = sample.get(city, (20, 28, "晴"))
    if unit == "华氏度":
        low, high = round(low * 9 / 5 + 32), round(high * 9 / 5 + 32)
    return f"{city} 未来 {days} 天:{text}{low}~{high}{unit}"


if __name__ == "__main__":
    print("name        =", get_weather.name)
    print("description =", get_weather.description)
    print("args        =", json.dumps(get_weather.args, ensure_ascii=False, indent=2))

    print(get_weather.invoke({"city": "北京"}))
    print(get_weather.invoke({"city": "杭州", "unit": "华氏度", "days": 3}))

    # 越界的参数会被 Pydantic 直接拦下,轮不到你的业务代码处理。
    try:
        get_weather.invoke({"city": "北京", "days": 99})
    except Exception as exc:  # noqa: BLE001  演示校验失败的现象
        print("参数校验失败:", type(exc).__name__)

加了 schema 之后有两个立竿见影的好处:

1模型填得更准

Field(description=...) 里的每一句话都会随工具一起进上下文。「城市名称,使用中文」这几个字,能省掉一半「Beijing 还是 北京」的扯皮。

2脏参数进不了业务

ge=1, le=7 这类约束由 Pydantic 在执行前校验。模型填了 99 天,异常在工具入口就抛出来了,轮不到你的业务代码处理

另一条路是 StructuredTool.from_function:当函数来自别人的模块、来自 SDK,你不想也不该去改它,就在外面包一层。它比装饰器多给了几个位置——同步实现、异步实现可以一起挂上:

structured_tool.py —— 把已有函数包成 Tool,同步与异步一起挂
"""StructuredTool.from_function:把「已经存在的函数」包成 Tool。

@tool 适合新写的函数——你能直接在函数上加装饰器。
但很多时候函数来自别人的模块、来自 SDK,你不想(也不该)去改它,
这时候用 StructuredTool.from_function 在外面包一层。
它还能同时挂上同步实现 func 与异步实现 coroutine。
"""

import asyncio
import json

from langchain_core.tools import StructuredTool
from pydantic import BaseModel, Field


# ---- 假设下面两个函数来自已有业务模块,一个字都不想改 ----------------
def search_docs(query: str, top_k: int = 3) -> str:
    hits = [f"《{query}》相关文档 {i + 1}" for i in range(top_k)]
    return ";".join(hits)


async def asearch_docs(query: str, top_k: int = 3) -> str:
    await asyncio.sleep(0)
    return search_docs(query, top_k)


# ---- 在外面包一层,把四要素补齐 ------------------------------------
class SearchInput(BaseModel):
    query: str = Field(description="检索关键词,写用户真正想找的那个词")
    top_k: int = Field(default=3, ge=1, le=10, description="返回的文档条数")


search_tool = StructuredTool.from_function(
    func=search_docs,                 # ④ 要调用的函数(同步)
    coroutine=asearch_docs,           #    异步实现,ainvoke 时自动走这条
    name="search_internal_docs",      # ① 名称:小写加下划线,别带空格
    description=(                     # ② 功能描述:写给模型看的
        "在公司内部知识库里检索文档。"
        "当用户问到内部流程、规范、历史方案时使用;"
        "不要用它查实时新闻或计算数值。"
    ),
    args_schema=SearchInput,          # ③ 参数结构
    return_direct=False,
)


if __name__ == "__main__":
    print("name        =", search_tool.name)
    print("description =", search_tool.description)
    print("args        =", json.dumps(search_tool.args, ensure_ascii=False))

    print(search_tool.invoke({"query": "报销流程", "top_k": 2}))
    print(asyncio.run(search_tool.ainvoke({"query": "值班制度"})))
场景用哪个原因
函数是你新写的@tool最短,一行装饰器;描述直接写文档字符串
函数是别人的、不能改StructuredTool.from_function不侵入原函数,在外面补齐名称与描述
同一个能力要同步也要异步StructuredTool.from_functionfunccoroutine 可以同时指定
要在运行时动态造工具StructuredTool.from_function它是普通调用,可以放进循环里按配置批量生成

2.4 执行循环:转几圈由模型决定,转的动作由框架做

工具备齐之后,create_agent 会把它们和模型装配成一个能自己转起来的循环。这个循环长这样:

图③ create_agent 的执行循环:调模型 → 判断 → 执行工具 → 回填 → 再调模型
图③ create_agent 的执行循环:调模型 → 判断 → 执行工具 → 回填 → 再调模型
① 调模型把问题 + 工具说明书一起交出去
② 判断返回里有没有工具调用
③ 执行工具跑的是你的代码
④ 回填结果作为工具消息追加进状态
⑤ 再调模型回到 ①,直到它不再要工具
收尾输出自然语言答复

和手写版相比,变化的不是流程,而是流程写在谁的代码里。手写版里你要维护一个 while、要记得回填、要判断什么时候停;交给 create_agent 之后这些都在框架里了,你只在两端出现:定义工具(循环的输入)和读最终状态(循环的输出)。

循环不会自己刹车 模型偶尔会在两个工具之间来回打转。没有上限的循环既烧钱又不会自己停,所以生产环境必须加步数限制——具体做法在 4.6 节的护栏里。

2.5 三代写法的演进:认得出旧代码,搬得动新写法

同一件事在 LangChain 的不同阶段有三种长相。老项目里前两代还大量存在,你要认得出来,也要知道搬过来之后对应哪一行。

图④ 三代写法的演进:一体化创建 → 分离式执行器 → 统一入口
图④ 三代写法的演进:一体化创建 → 分离式执行器 → 统一入口
api_eras.py —— 三代写法并排,以及逐项的搬迁关系
"""三代写法的演进:同一个需求,三种年代的代码长相。

同一件事——「给模型一个搜索工具,让它自己决定要不要用」——
在 LangChain 的不同阶段有三种写法。前两种在老项目里还大量存在,
你要认得出来,也要知道搬到当代写法之后对应哪一行。

本文件里只有第三代是可运行的;前两代以字符串形式保留长相,
避免导入已经不在 langchain 主包里的名字。
"""

ERA_1 = '''
# 第一代:一体化创建,三行出一个执行器
from langchain.agents import initialize_agent, AgentType

agent_executor = initialize_agent(
    tools=[search_tool],
    llm=llm,
    agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,   # 用枚举挑一套内置提示词
    verbose=True,
)
agent_executor.invoke("今天北京的天气怎么样?")

# 上手快,但提示词是内置的、看不见也改不动;
# 想换一句措辞就只能换一个 AgentType 碰运气。
'''

ERA_2 = '''
# 第二代:Agent 与运行时分离
from langchain.agents import AgentExecutor, create_react_agent
from langchain import hub

prompt = hub.pull("hwchase17/react")           # 提示词终于拿得到了
agent = create_react_agent(llm=llm, tools=tools, prompt=prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools)
agent_executor.invoke({"input": "今天北京的天气怎么样?"})

# 决策逻辑(agent)与执行循环(AgentExecutor)分开了,提示词可自定义;
# 代价是代码变长,而且要在循环里插一段自己的逻辑仍然很别扭。
'''

ERA_3 = '''
# 第三代:统一入口 create_agent
from langchain.agents import create_agent

agent = create_agent(
    model="openai:gpt-4o-mini",
    tools=tools,
    system_prompt="今天北京的天气怎么样?这类实时问题必须调用工具。",
    middleware=[...],          # 想插东西就插 middleware,不用改循环
)
agent.invoke({"messages": [{"role": "user", "content": "今天北京的天气怎么样?"}]})
'''

# 逐项的搬迁关系,照着改即可
MIGRATION = [
    ("导入路径", "langgraph.prebuilt", "langchain.agents"),
    ("工厂函数", "create_react_agent", "create_agent"),
    ("提示词参数", "prompt=", "system_prompt=(传字符串)"),
    ("动态提示词", "自己拼 prompt", "@dynamic_prompt middleware"),
    ("调用前后插逻辑", "pre_model_hook / post_model_hook / state_modifier",
     "before_model / after_model / @wrap_model_call / @wrap_tool_call"),
    ("结构化输出", "response_format=(含 prompted 形式)",
     "response_format=ToolStrategy(...) 或 ProviderStrategy(...)"),
    ("流式节点名", '"agent"', '"model"'),
    ("运行时上下文", 'config["configurable"] 里塞', "invoke(context=...) + context_schema="),
    ("入参形态", '{"input": "..."}', '{"messages": [{"role": "user", "content": "..."}]}'),
]


if __name__ == "__main__":
    for title, block in (("第一代", ERA_1), ("第二代", ERA_2), ("第三代", ERA_3)):
        print("=" * 66)
        print(title, block)

    print("=" * 66)
    print("%-16s %-42s %s" % ("变化点", "旧写法", "当代写法"))
    for row in MIGRATION:
        print("%-16s %-42s %s" % row)
变化点旧写法当代写法
创建方式initialize_agent + AgentType 枚举create_agent 一个工厂函数装配好
分离式写法create_react_agent + AgentExecutor同样收敛到 create_agent
导入路径langgraph.prebuiltlangchain.agents
提示词参数prompt=system_prompt=,传字符串,不要传消息对象
动态提示词自己拼字符串@dynamic_prompt middleware
循环里插逻辑pre_model_hook / post_model_hook / state_modifierbefore_model / after_model / wrap_model_call / wrap_tool_call
结构化输出response_format=(含提示词形式)ToolStrategy / ProviderStrategy,提示词形式已移除
流式节点名"agent""model"
入参形态{"input": "..."}{"messages": [{"role": "user", "content": "..."}]}
旧课程代码跑不通,多半卡在这三处initialize_agentAgentType 属于 legacy,已经搬到 langchain-classic,不再是推荐写法;
from langchain_community.llms import Ollama 换成伙伴包 langchain-ollama
agent.run(...) 这种单字符串入口换成 agent.invoke({"messages": [...]})
看到 ZERO_SHOT_REACT_DESCRIPTION 这种枚举,基本可以判定这段代码来自 0.3 时代。
那 ReAct 模式和 Function Call 模式的区分呢? 旧写法里要在 AgentType 里二选一:文本推理(模型把思考过程写成文字,系统再解析)还是结构化调用(模型直接返回结构化的调用意图)。当代写法不用你选了——create_agent 默认走结构化调用这条路,因为主流模型都已经原生支持。两者的机制差异属于 Agent 原理那一讲的内容。

03最小代码:四步跑通第一个 Agent

一个工具、一句人设、一次调用,先把最短的路走完

剥掉所有业务之后,用 create_agent 搭一个 Agent 只有四步。整个过程里你一行循环都不用写——「要不要再调一次工具」这件事,框架在替你判断。

① 定义工具普通函数 + @tool
② 装配 Agent模型 + 工具列表 + system_prompt
③ invoke{"messages": [...]}
④ 读结果messages 的最后一条
min_agent.py —— 最小可跑的 Agent,复制即用可复用模板
"""最小可跑的 Agent:一个工具 + 一句 system_prompt + 一次 invoke。

这是 LangChain 1.x 里构建 Agent 的当代写法:
    from langchain.agents import create_agent

四步:定义工具 → 建 Agent → invoke → 取最后一条消息。
循环(要不要再调一次工具)由 create_agent 内部替你转,你不用手写 while。

环境:
    pip install "langchain>=1.0" langchain-openai
    export OPENAI_API_KEY=...
    export OPENAI_BASE_URL=...        # 用官方接口时可以不设
"""

import os

from langchain.agents import create_agent
from langchain.tools import tool


@tool
def get_city_population(city: str) -> str:
    """查询一个城市的常住人口数量(单位:万人)。需要人口数据时使用。"""
    table = {"北京": 2183, "上海": 2487, "郑州": 1300, "杭州": 1252}
    if city not in table:
        return f"没有 {city} 的人口数据"
    return f"{city} 常住人口约 {table[city]} 万人"


def build_agent():
    """把模型、工具、system_prompt 装配成一个 Agent。"""
    return create_agent(
        model="openai:gpt-4o-mini",          # 也可以传已经实例化好的模型对象
        tools=[get_city_population],         # 工具列表,就是那个「工具箱」
        system_prompt=(                      # 传字符串,不要传 SystemMessage 对象
            "你是一个数据助手。需要具体数字时必须调用工具,"
            "工具没有数据就如实说没有,不要自己编。"
        ),
    )


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
        return

    agent = build_agent()
    # 入参固定是一个字典,键 messages 装一个消息列表
    result = agent.invoke(
        {"messages": [{"role": "user", "content": "北京和杭州哪个人多?多多少万?"}]}
    )

    # 返回值也是状态字典,messages 里是这一轮跑出来的完整消息链
    for m in result["messages"]:
        print(f"[{m.type:9s}]", (m.text() or "").strip()[:80],
              "| tool_calls:", getattr(m, "tool_calls", None))

    print("\n最终答复:", result["messages"][-1].text())


if __name__ == "__main__":
    main()
三个和旧写法不一样的地方,第一次写最容易卡住入参是字典不是字符串{"messages": [{"role": "user", "content": "..."}]},旧写法的 agent.run("问题"){"input": "..."} 都不再是当代形态;
人设参数叫 system_prompt,直接传字符串,不要传消息对象;
返回的也是状态字典result["messages"] 是这一轮跑出来的完整消息链,最后一条才是给用户看的答复,中间那些是模型的决策与工具回执。

result["messages"] 整个打印出来,你会看到一条典型的四段式链路:

序号消息类型内容
human北京和杭州哪个人多?多多少万?
ai正文为空,带着两条工具调用意图:查北京、查杭州
tool × 2两条回执:常住人口约 2183 万人 / 约 1252 万人
ai「北京比杭州多约 931 万人」——这次没有工具调用,循环到此结束

注意第 ② 步:模型一次就发出了两条调用意图,框架会并行执行、逐条回填。这是当代写法比手写循环省心的地方之一——并行调用的收集与配对不用你操心。

环境与密钥 依赖 pip install "langchain>=1.0" langchain-openai,Python 需要 3.10 以上。密钥一律走环境变量 os.environ.get(...)不要硬编码进源码,更不要把 .env 提交进版本库。换成别家模型时,把 model= 的字符串换掉即可,其余代码不动。

04完整案例:一个会自己拿主意的门店助手

三件自定义工具串成一个 Agent,再依次接上流式观察、会话记忆、结构化输出与护栏

需求很具体:连锁咖啡店的店员想问一句「杭州西湖店的拿铁豆还有多少?按九折买 30 袋要多少钱?」,希望助手自己把这件事办完。这句话里其实藏着两件事——先查库存,再算价钱,而且第二件事要用到第一件的结果。

关键在于:你不会写「先调查询、再调计算」这行代码。你只把三件工具交出去,顺序由模型自己定。

4.1 工具箱:查询、计算、写入各一件

三件工具刚好覆盖三种典型形态,边界策略也完全不同:

工具类型边界策略
query_stock读操作放开用;查不到时返回「查无记录」而不是抛异常,让模型如实转述
quote_price算操作参数在 schema 层就卡死取值范围,脏数据进不了计算
create_order写操作自己校验库存、自己限额;描述里写明「只有用户明确说下单才用」
shop_tools.py —— 三件工具:查库存、算价钱、下订单
"""门店助手的工具箱:三件真正能跑的工具。

- query_stock   查某个门店某个商品还有多少件(查询类)
- quote_price   按数量与折扣算总价(计算类)
- create_order  下一张订单(写操作,带安全边界)

工具的边界原则:
读操作放开;写操作必须自己校验参数、自己限额,
不要指望 system_prompt 能拦住一个填错参数的模型。
"""

from datetime import datetime

from langchain.tools import tool
from pydantic import BaseModel, Field

# 演示用的内存数据,真项目换成数据库查询即可
STOCK = {
    ("北京朝阳店", "拿铁豆"): 120,
    ("北京朝阳店", "冷萃瓶"): 8,
    ("杭州西湖店", "拿铁豆"): 40,
    ("杭州西湖店", "冷萃瓶"): 0,
}
PRICE = {"拿铁豆": 68.0, "冷萃瓶": 25.0}
ORDERS: list[dict] = []
MAX_QTY_PER_ORDER = 500


class StockInput(BaseModel):
    shop: str = Field(description="门店名称,例如 北京朝阳店、杭州西湖店")
    item: str = Field(description="商品名称,例如 拿铁豆、冷萃瓶")


@tool(args_schema=StockInput)
def query_stock(shop: str, item: str) -> str:
    """查询某个门店某个商品的当前库存件数。
    当用户问「还有没有货」「库存多少」「够不够」时使用。
    入参是门店名与商品名,两者都必须由用户给出,不要替用户猜门店。"""
    if (shop, item) not in STOCK:
        return f"查无记录:{shop} 没有登记过 {item}"
    return f"{shop}{item} 当前库存 {STOCK[(shop, item)]} 件"


class QuoteInput(BaseModel):
    item: str = Field(description="商品名称")
    qty: int = Field(ge=1, le=MAX_QTY_PER_ORDER, description="购买数量,正整数")
    discount: float = Field(default=1.0, gt=0, le=1,
                            description="折扣系数,0.9 表示九折,默认不打折")


@tool(args_schema=QuoteInput)
def quote_price(item: str, qty: int, discount: float = 1.0) -> str:
    """按商品单价、数量和折扣计算订单总价。
    当用户问「多少钱」「一共几钱」「打完折是多少」时使用。
    这个工具只算钱,不查库存,也不会真的下单。"""
    if item not in PRICE:
        return f"没有 {item} 的价格"
    total = PRICE[item] * qty * discount
    return (f"{item} 单价 {PRICE[item]:.2f} 元 × {qty} 件 × 折扣 {discount} "
            f"= {total:.2f} 元")


class OrderInput(BaseModel):
    shop: str = Field(description="下单门店")
    item: str = Field(description="商品名称")
    qty: int = Field(ge=1, le=MAX_QTY_PER_ORDER, description="下单数量")


@tool(args_schema=OrderInput)
def create_order(shop: str, item: str, qty: int) -> str:
    """为指定门店创建一张采购订单。这是写操作,会真的产生一条订单记录。
    只有在用户明确说了「下单」「就要这些」之后才使用;
    库存不足时不要下单,先把缺口告诉用户。"""
    have = STOCK.get((shop, item))
    if have is None:
        return f"下单失败:{shop} 没有 {item} 这个商品"
    if qty > have:
        return f"下单失败:{shop}{item} 只剩 {have} 件,不足 {qty} 件"
    STOCK[(shop, item)] = have - qty
    oid = f"SO{datetime.now():%Y%m%d}{len(ORDERS) + 1:03d}"
    ORDERS.append({"id": oid, "shop": shop, "item": item, "qty": qty})
    return f"下单成功,订单号 {oid}{shop}{item} 剩余 {STOCK[(shop, item)]} 件"


SHOP_TOOLS = [query_stock, quote_price, create_order]


if __name__ == "__main__":
    print(query_stock.invoke({"shop": "北京朝阳店", "item": "冷萃瓶"}))
    print(quote_price.invoke({"item": "拿铁豆", "qty": 30, "discount": 0.9}))
    print(create_order.invoke({"shop": "北京朝阳店", "item": "冷萃瓶", "qty": 20}))
仔细读这三段 description,它们在防不同的事 query_stock 那句「两者都必须由用户给出,不要替用户猜门店」,是在防模型随手挑一家店;
quote_price 那句「只算钱,不查库存,也不会真的下单」,是在划清边界、防它抢别的工具的活;
create_order 那句「这是写操作,会真的产生一条记录」,是在提高它动手的门槛。
这些话都不是注释——它们会原样进入模型的上下文,是真正起作用的约束。
提示词管不住的,代码必须管住 描述里写了「库存不足不要下单」,但模型仍有可能填一个超量的参数。所以 create_order 自己又查了一遍库存,不够就直接拒绝。凡是写操作,校验必须落在函数里,不能只落在 descriptionsystem_prompt 里。

4.2 装配与运行:顺序是模型定的

shop_agent.py —— 三件工具装配成一个能自主决策的 Agent
"""门店助手完整案例:三个自定义工具串成一个能自主决策的 Agent。

用户一句「杭州西湖店的拿铁豆还有多少?按九折买 30 袋要多少钱?」
里面藏着两件事:先查库存、再算价钱。
你不需要写「先调哪个再调哪个」——把工具交出去,顺序由模型自己定。

环境:
    pip install "langchain>=1.0" langchain-openai
    export OPENAI_API_KEY=...
    export OPENAI_BASE_URL=...
"""

import os

from langchain.agents import create_agent

from shop_tools import SHOP_TOOLS

SYSTEM_PROMPT = """你是一家连锁咖啡店的门店助手。

规则:
1. 涉及库存、价格、下单的问题一律调用工具,不要凭印象回答。
2. 用户没说门店时,先问清楚是哪家店,不要自己挑一家。
3. 下单前必须先查库存;库存不足就把缺口说明白,不要下单。
4. 工具返回「查无记录」时如实转述,不要编造数字。
"""


def build_agent():
    return create_agent(
        model="openai:gpt-4o-mini",
        tools=SHOP_TOOLS,
        system_prompt=SYSTEM_PROMPT,
    )


def run(agent, question: str) -> None:
    print("=" * 66)
    print("用户:", question)
    result = agent.invoke({"messages": [{"role": "user", "content": question}]})

    for m in result["messages"]:
        if m.type == "ai" and getattr(m, "tool_calls", None):
            for call in m.tool_calls:
                print(f"  → 决定调用 {call['name']},参数 {call['args']}")
        elif m.type == "tool":
            print(f"  ← {m.name} 返回:{m.content}")
    print("助手:", result["messages"][-1].text())


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
        return
    agent = build_agent()

    # 一轮里要连着用两个工具:查库存 → 算价钱
    run(agent, "杭州西湖店的拿铁豆还有多少?按九折买 30 袋要多少钱?")

    # 库存为 0 的情况,看它会不会老老实实说没货
    run(agent, "杭州西湖店的冷萃瓶帮我下 10 件")

    # 缺少门店信息,看它会不会反问而不是瞎选一家
    run(agent, "拿铁豆还有货吗")


if __name__ == "__main__":
    main()

三个问题各自测一种行为,输出大致是这样:

提问它做了什么说明
拿铁豆还有多少?九折 30 袋多少钱?query_stock,再 quote_price两件事的先后是它自己排的,你的代码里没有这个顺序
冷萃瓶帮我下 10 件先查库存 → 发现为 0 → 不下单工具返回的缺口被如实转述,没有编一个成功的订单号
拿铁豆还有货吗不调工具,直接反问哪家门店参数缺失时反问而不是瞎猜,靠的是描述与人设里的那两句话

第一个问题跑完,messages 里会留下六条记录:用户提问 → 工具调用意图(查库存)→ 回执 → 工具调用意图(算价钱)→ 回执 → 最终答复。两轮「调用—执行—回填」加一次收尾,和手写版的形状一模一样,只是这次循环写在框架里。

4.3 把循环看清楚:用 stream 逐节点观察

invoke 只给最终结果,出了问题两眼一抹黑。调试 Agent 的正确姿势是 stream:每个节点跑完就吐一块更新出来,你能看见它在第几步决定调什么、工具返回了什么。

stream_watch.py —— 逐节点观察执行循环,以及逐字流式输出
"""把 Agent 的执行循环看清楚:用 stream 逐节点观察。

invoke 只给你最后的结果,出了问题两眼一抹黑。
stream 会在每个节点跑完后吐一块更新出来,你能看见
模型什么时候决定调工具、工具返回了什么、它又转回模型第几次。

节点名在 LangChain 1.x 里是 "model" 与 "tools"。
(旧版 langgraph.prebuilt.create_react_agent 里模型节点叫 "agent",
 迁移到 create_agent 之后这个名字变了,按 "agent" 取值会一直取到空。)
"""

import os

from langchain.agents import create_agent

from shop_tools import SHOP_TOOLS


def watch(agent, question: str) -> None:
    print("用户:", question)
    step = 0
    for chunk in agent.stream(
        {"messages": [{"role": "user", "content": question}]},
        stream_mode="updates",
    ):
        step += 1
        for node, update in chunk.items():
            msgs = update.get("messages", []) if isinstance(update, dict) else []
            for m in msgs:
                if node == "model":
                    calls = getattr(m, "tool_calls", None)
                    if calls:
                        names = "、".join(c["name"] for c in calls)
                        print(f"第 {step} 步 [model] 决定调用:{names}")
                    else:
                        print(f"第 {step} 步 [model] 给出最终答复:{m.text()[:60]}")
                elif node == "tools":
                    print(f"第 {step} 步 [tools] {m.name}{m.content[:60]}")
    print("-" * 66)


def token_stream(agent, question: str) -> None:
    """只想看最终答复一个字一个字吐出来,用 stream_mode="messages"。"""
    for token, meta in agent.stream(
        {"messages": [{"role": "user", "content": question}]},
        stream_mode="messages",
    ):
        if meta.get("langgraph_node") == "model" and token.text():
            print(token.text(), end="", flush=True)
    print()


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
        return
    agent = create_agent(
        model="openai:gpt-4o-mini",
        tools=SHOP_TOOLS,
        system_prompt="你是门店助手,涉及库存与价格一律调用工具。",
    )
    watch(agent, "北京朝阳店的拿铁豆还有多少?买 20 袋原价多少钱?")
    token_stream(agent, "再帮我算一下 50 袋打八折多少钱")


if __name__ == "__main__":
    main()
流式模式吐出来的是什么时候用
stream_mode="updates"每个节点跑完后的状态更新调试首选:看它调了哪些工具、转了几圈
stream_mode="messages"一个个 token做界面:让最终答复一个字一个字吐出来
stream_mode="values"每一步之后的完整状态需要每步都拿到全量消息链时
⚠️ 节点名是 "model",不是 "agent" 这是从旧写法搬过来最容易踩的坑:过去模型节点叫 "agent"create_agent 里它叫 "model"(工具节点叫 "tools")。按 "agent" 取值不会报错,只会一直取到空,让人误以为流式没生效。

4.4 让它记住上一轮:checkpointer + thread_id

上面的每次 invoke 都是独立的——问完「北京朝阳店的拿铁豆」,再问「那这家店的冷萃瓶呢」,它并不知道「这家店」是哪家。Agent 侧接记忆只要两样东西:create_agent 传一个 checkpointer,调用时带上 thread_id

agent_memory.py —— 用 checkpointer 与 thread_id 做会话记忆
"""让 Agent 记住上一轮:checkpointer + thread_id。

Agent 的每一轮 invoke 默认是互相独立的——上一轮查过哪家门店,
这一轮它并不知道。给 create_agent 传一个 checkpointer,
再在调用时带上 thread_id,状态就会被持久化并按会话隔离。

记忆本身的原理(模型没有记忆、历史要一轮轮重新塞回去)在会话记忆那一讲,
这里只讲 Agent 侧这条路子怎么接。
"""

import os

from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver

from shop_tools import SHOP_TOOLS


def build_agent():
    return create_agent(
        model="openai:gpt-4o-mini",
        tools=SHOP_TOOLS,
        system_prompt="你是门店助手,涉及库存与价格一律调用工具。",
        # 演示用内存实现;进程一退就没了。
        # 要跨进程留存,换成 SQLite / Postgres 的 saver 实现。
        checkpointer=InMemorySaver(),
    )


def ask(agent, text: str, thread_id: str) -> str:
    cfg = {"configurable": {"thread_id": thread_id}}
    result = agent.invoke({"messages": [{"role": "user", "content": text}]}, cfg)
    answer = result["messages"][-1].text()
    print(f"[{thread_id}] 用户:{text}")
    print(f"[{thread_id}] 助手:{answer}\n")
    return answer


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
        return
    agent = build_agent()

    # 同一个 thread_id:第二句里的「这家店」指代第一句的门店
    ask(agent, "北京朝阳店的拿铁豆还有多少?", thread_id="shop-001")
    ask(agent, "那这家店的冷萃瓶呢?", thread_id="shop-001")

    # 换一个 thread_id:互不相通,它不知道刚才聊的是哪家店
    ask(agent, "那这家店的冷萃瓶呢?", thread_id="shop-999")

    # 想看某个会话当前存了什么,直接读状态
    state = agent.get_state({"configurable": {"thread_id": "shop-001"}})
    print("shop-001 累计消息条数:", len(state.values["messages"]))


if __name__ == "__main__":
    main()
概念作用
checkpointer把状态持久化下来。每一步开始时读、关键步骤完成时写
thread_id会话的身份证。不同 thread 之间完全隔离,A 用户的上文不会串到 B 用户那里
InMemorySaver内存实现,进程一退就没了。要跨进程留存就换成数据库支撑的实现
agent.get_state(cfg)随时把某个会话当前存了什么捞出来看
记忆的原理不在这儿 「模型本身没有记忆、所谓记住全靠每轮把历史重新塞回请求里」,以及修剪、摘要这些策略,在会话记忆那一讲。这里只给出 Agent 侧的接法:checkpointer 负责存,thread_id 负责分。

4.5 结构化输出:让最终答复直接能入库

Agent 跑完循环通常吐一段人话,好看但没法直接用。传一个 response_format,结果里就会多出一个 structured_response——一个通过校验的 Pydantic 对象。

structured_result.py —— 用 ToolStrategy 拿到结构化的最终结论
"""让 Agent 的最终答复是一个结构化对象,而不是一段人话。

Agent 跑完循环之后通常吐一段自然语言,好看但没法直接入库。
给 create_agent 传 response_format,最终结果里会多出 structured_response,
它是一个通过校验的 Pydantic 对象,字段名、类型都由你定。

ToolStrategy 的做法是把这个 schema 也当作一个「工具」交给模型,
让它在结束前按 schema 填一次——所以对支持工具调用的模型都能用。
"""

import os

from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy
from pydantic import BaseModel, Field

from shop_tools import SHOP_TOOLS


class StockAnswer(BaseModel):
    """一次库存问询的结构化结论。"""

    shop: str = Field(description="门店名称")
    item: str = Field(description="商品名称")
    qty: int = Field(description="当前库存件数,查不到写 -1")
    enough: bool = Field(description="是否满足用户要的数量")
    note: str = Field(description="一句话说明,写给人看")


def build_agent():
    return create_agent(
        model="openai:gpt-4o-mini",
        tools=SHOP_TOOLS,
        system_prompt="你是门店助手,涉及库存与价格一律调用工具。",
        response_format=ToolStrategy(StockAnswer),
    )


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
        return

    agent = build_agent()
    result = agent.invoke(
        {"messages": [{"role": "user", "content": "北京朝阳店的冷萃瓶够不够 20 件?"}]}
    )

    answer: StockAnswer = result["structured_response"]
    print("类型      :", type(answer).__name__)
    print("门店      :", answer.shop)
    print("商品      :", answer.item)
    print("库存      :", answer.qty)
    print("是否够用  :", answer.enough)
    print("说明      :", answer.note)

    # 拿到的是对象,直接进数据库或者拼下一段逻辑,不用再去正则抠数字
    print("入库用的字典:", answer.model_dump())


if __name__ == "__main__":
    main()

ToolStrategy 的做法是把你的 schema 也当成一件工具交给模型,让它在收尾前按 schema 填一次。所以它对任何支持工具调用的模型都能用,不挑服务商。另一个选项 ProviderStrategy 走的是模型服务商原生的结构化输出能力,支持的服务商上更稳,但不是所有家都有。

旧写法里的提示词式结构化输出已经没有了 过去可以靠在提示词里写「请按这个 JSON 格式回答」来凑;当代写法里这种形式已移除,要么 ToolStrategy,要么 ProviderStrategy。好处是校验不过会重来,而不是给你一段拼不出对象的字符串

4.6 护栏:工具会报错、循环会失控、写操作要复核

上面的 Agent 拿去演示没问题,上生产还差三层护栏。这三层都通过 middleware 挂上去——它们插在循环的缝隙里,不用改循环本身。

钩子插在哪典型用途
before_agent整个任务开始前加载用户档案、准备上下文
before_model每次调模型之前步数刹车、裁剪历史
wrap_model_call把调模型整个包住改请求、失败重试、动态换模型
wrap_tool_call把执行工具整个包住异常兜底、超时、审计日志
after_model每次模型返回之后敏感信息过滤、输出校验
after_agent整个任务结束后落库、统计、清理
guard_middleware.py —— 异常兜底、步数刹车、人工确认、历史压缩
"""给 Agent 的循环加护栏:middleware 的四个常用钩子。

create_agent 内部的循环是「调模型 → 执行工具 → 回填 → 再调模型」,
middleware 就是在这条循环的缝隙里插自己的代码:

    before_agent      整个任务开始前跑一次
    before_model      每次调模型之前
    wrap_model_call   把调模型这一步整个包住,可以改请求、可以重试
    wrap_tool_call    把执行工具这一步整个包住,异常在这里兜住
    after_model       每次模型返回之后
    after_agent       整个任务结束后跑一次

下面三段分别解决三个真会踩到的问题:工具报错、步数失控、写操作没人复核。
"""

from collections.abc import Callable

from langchain.agents import create_agent
from langchain.agents.middleware import (
    HumanInTheLoopMiddleware,
    SummarizationMiddleware,
    before_model,
    wrap_tool_call,
)
from langchain.messages import ToolMessage
from langchain.tools.tool_node import ToolCallRequest

MAX_MODEL_CALLS = 8


@wrap_tool_call
def catch_tool_errors(
    request: ToolCallRequest,
    handler: Callable[[ToolCallRequest], ToolMessage],
) -> ToolMessage:
    """工具抛异常时,把异常转成一条 ToolMessage 还给模型。

    不加这一层,工具里一个 KeyError 就会把整个 Agent 打断;
    加了之后模型看得见错误内容,通常会改参数重试一次。
    """
    try:
        return handler(request)
    except Exception as exc:  # noqa: BLE001  这里就是要兜住所有异常
        return ToolMessage(
            content=f"工具执行失败:{exc}。请检查参数后重试,或者告诉用户查不到。",
            tool_call_id=request.tool_call["id"],
        )


@before_model
def limit_steps(state) -> dict | None:
    """步数刹车:模型被调用超过上限就强行收尾。

    模型偶尔会在两个工具之间来回打转。没有上限的循环
    既烧钱又不会自己停,生产环境必须有这一层。
    """
    used = sum(1 for m in state["messages"] if m.type == "ai")
    if used >= MAX_MODEL_CALLS:
        return {
            "messages": [{"role": "assistant",
                          "content": "这个问题我尝试了多轮仍未解决,先交给人工处理。"}],
            "jump_to": "end",
        }
    return None


def build_guarded_agent(tools):
    """把三层护栏一起装上。"""
    return create_agent(
        model="openai:gpt-4o-mini",
        tools=tools,
        system_prompt="你是门店助手,涉及库存与价格一律调用工具。",
        middleware=[
            limit_steps,            # 步数上限
            catch_tool_errors,      # 工具异常兜底
            # 写操作在执行前暂停,等人点头;把工具名列进 interrupt_on
            HumanInTheLoopMiddleware(
                interrupt_on={"create_order": True},
            ),
            # 历史太长时自动压缩,避免把上下文窗口撑爆
            SummarizationMiddleware(
                model="openai:gpt-4o-mini",
                max_tokens_before_summary=4000,
            ),
        ],
    )


if __name__ == "__main__":
    from shop_tools import SHOP_TOOLS

    agent = build_guarded_agent(SHOP_TOOLS)
    print("已装配的护栏:步数上限 / 工具异常兜底 / 写操作人工确认 / 历史摘要压缩")
    print("提示:装了 HumanInTheLoopMiddleware 之后必须配 checkpointer,")
    print("      否则中断之后没有地方存状态,恢复不回来。")

四层护栏各治一种病:

1工具异常兜底

不加这层,工具里一个 KeyError 就会把整个任务打断。加了之后异常变成一条消息还给模型,它通常会改参数重试一次。

2步数刹车

模型可能在两个工具之间来回打转。超过上限就强行收尾转人工,比烧完预算才发现要好

3写操作人工确认

HumanInTheLoopMiddleware 把指定工具拦在执行之前,把「打算调什么、参数填了什么」交给人看一眼。

4历史自动压缩

SummarizationMiddleware 在历史撑爆上下文窗口之前先做摘要,长会话必备。

人工确认这条路子值得单独跑一遍,因为它涉及「中断—恢复」,写法和别的护栏不一样:

human_in_the_loop.py —— 写操作前暂停,人点头之后再执行
"""写操作前停一下:HumanInTheLoopMiddleware 的完整跑法。

查库存查错了最多是答案不准;下错单是真赔钱。
所以凡是写操作,正确的姿势是让 Agent 在执行工具之前停住,
把「它打算调哪个工具、参数填了什么」交给人看一眼。

中断要能恢复,就必须有 checkpointer 存状态,还要有 thread_id 找回来。
"""

import os

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

from shop_tools import SHOP_TOOLS


def build_agent():
    return create_agent(
        model="openai:gpt-4o-mini",
        tools=SHOP_TOOLS,
        system_prompt="你是门店助手。下单前先查库存,库存不足不要下单。",
        middleware=[
            HumanInTheLoopMiddleware(
                # 只拦写操作;查询类工具照常自动执行
                interrupt_on={"create_order": True},
                description_prefix="以下写操作需要人工确认:",
            )
        ],
        checkpointer=InMemorySaver(),
    )


def main() -> None:
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
        return

    agent = build_agent()
    cfg = {"configurable": {"thread_id": "hitl-001"}}

    result = agent.invoke(
        {"messages": [{"role": "user",
                       "content": "北京朝阳店的拿铁豆下单 10 袋"}]},
        cfg,
    )

    # 被中断时,结果里会带上 __interrupt__,里面是等待确认的工具调用
    interrupts = result.get("__interrupt__")
    if not interrupts:
        print("这一轮没有触发确认:", result["messages"][-1].text())
        return

    print("等待确认的动作:", interrupts[0].value)

    # 人点头 → accept;也可以 edit 改参数,或者 reject 附上拒绝原因
    approved = agent.invoke(
        Command(resume={"decisions": [{"type": "accept"}]}),
        cfg,
    )
    print("确认之后:", approved["messages"][-1].text())

    # 拒绝的写法(换成这一段即可):
    # agent.invoke(
    #     Command(resume={"decisions": [
    #         {"type": "reject", "message": "本月采购额度已用完,先不要下单"}
    #     ]}),
    #     cfg,
    # )


if __name__ == "__main__":
    main()
⚠️ 人工确认必须配 checkpointer 中断之后状态得有地方存、恢复时得找得回来,所以 HumanInTheLoopMiddleware 必须和 checkpointer 一起用,并且每次调用都带同一个 thread_id。少了任何一样,中断之后就接不回去了。
恢复时的三种决定:accept 照原样执行、edit 改完参数再执行、reject 拒绝并把原因告诉模型。
✅ 到这里,一个能交付的 Agent 该有的东西齐了 三件描述清楚的工具 · 一句约束明确的人设 · 会话记忆 · 结构化输出 · 四层护栏。剩下的都是业务——换掉工具里的数据源,这套骨架原样能用。界面怎么接(把这套后厨接到一个点单台上)在 Streamlit 那一讲。

05骨架模板:拿去改就能用

一份 Agent 骨架、一份工具骨架,改 TODO 处即可

5.1 Agent 骨架:四块结构,五处 TODO

把案例里的共性抽出来就是它。结构固定成四块,改动只发生在工具区和装配区:

区块要不要改内容
① 工具区必改你的业务能力,一件工具做一件事,描述写清四个问题
② 护栏区一般不改工具异常兜底 + 步数上限,两段照抄即可
③ 装配区改两处换模型、换人设,其余不动
④ 调用区不改ask 拿结果、watch 看过程,调试和上线各用一个
agent_skeleton.py —— 带护栏与会话记忆的 Agent 骨架可复用模板
"""可复用骨架:一个带护栏的 Agent,改五处 TODO 就能上手。

结构固定为四块:
    ① 工具区    你的业务能力,一个工具做一件事
    ② 护栏区    工具异常兜底 + 步数上限 + 写操作确认
    ③ 装配区    create_agent 把模型、工具、提示词、护栏拼起来
    ④ 调用区    invoke 拿结果,stream 看过程

环境:
    pip install "langchain>=1.0" langchain-openai
    export OPENAI_API_KEY=...
    export OPENAI_BASE_URL=...
"""

import os
from collections.abc import Callable

from langchain.agents import create_agent
from langchain.agents.middleware import before_model, wrap_tool_call
from langchain.messages import ToolMessage
from langchain.tools import tool
from langchain.tools.tool_node import ToolCallRequest
from langgraph.checkpoint.memory import InMemorySaver
from pydantic import BaseModel, Field

MAX_MODEL_CALLS = 8


# ---------- ① 工具区:TODO 1 换成你自己的工具 ------------------------
class LookupInput(BaseModel):
    keyword: str = Field(description="TODO 2:说清这个参数是什么、怎么填、有什么格式要求")


@tool(args_schema=LookupInput)
def lookup_something(keyword: str) -> str:
    """TODO 3:这段描述是写给模型看的,决定它选不选这个工具。
    要回答四件事:这个工具干什么、用户说什么话时该用它、
    参数从哪里取、什么情况下不要用它。"""
    return f"这里返回 {keyword} 的查询结果"


TOOLS = [lookup_something]


# ---------- ② 护栏区:一般不用改 --------------------------------------
@wrap_tool_call
def catch_tool_errors(
    request: ToolCallRequest,
    handler: Callable[[ToolCallRequest], ToolMessage],
) -> ToolMessage:
    """工具抛异常时转成消息还给模型,别让整个任务直接崩掉。"""
    try:
        return handler(request)
    except Exception as exc:  # noqa: BLE001
        return ToolMessage(
            content=f"工具执行失败:{exc}。请检查参数后重试。",
            tool_call_id=request.tool_call["id"],
        )


@before_model
def limit_steps(state) -> dict | None:
    """步数刹车,避免模型在工具之间无限打转。"""
    used = sum(1 for m in state["messages"] if m.type == "ai")
    if used >= MAX_MODEL_CALLS:
        return {
            "messages": [{"role": "assistant", "content": "多轮尝试未果,转人工处理。"}],
            "jump_to": "end",
        }
    return None


# ---------- ③ 装配区:TODO 4 换模型、TODO 5 换人设 --------------------
def build_agent():
    return create_agent(
        model="openai:gpt-4o-mini",                      # TODO 4
        tools=TOOLS,
        system_prompt=(                                  # TODO 5
            "你是一个业务助手。需要具体数据时一律调用工具,"
            "工具查不到就如实说查不到,不要编造。"
        ),
        middleware=[limit_steps, catch_tool_errors],
        checkpointer=InMemorySaver(),
    )


# ---------- ④ 调用区 --------------------------------------------------
def ask(agent, text: str, thread_id: str = "default") -> str:
    cfg = {"configurable": {"thread_id": thread_id}}
    result = agent.invoke({"messages": [{"role": "user", "content": text}]}, cfg)
    return result["messages"][-1].text()


def watch(agent, text: str, thread_id: str = "default") -> None:
    cfg = {"configurable": {"thread_id": thread_id}}
    for chunk in agent.stream(
        {"messages": [{"role": "user", "content": text}]}, cfg, stream_mode="updates"
    ):
        for node, update in chunk.items():
            print("[%s]" % node, update)


if __name__ == "__main__":
    if not os.environ.get("OPENAI_API_KEY"):
        print("先配置 OPENAI_API_KEY 再运行")
    else:
        bot = build_agent()
        print(ask(bot, "帮我查一下 报销流程"))
✅ 复制后你只需要改这五处 TODO 1 换成你自己的工具函数 · TODO 2 把每个参数的含义写进 Field(description=...) · TODO 3 把工具描述写全(干什么、何时用、参数怎么填、何时别用)· TODO 4 换模型 · TODO 5 换人设。护栏和调用区一个字都不用动。

5.2 工具骨架:三类工具,三种写法边界

工具不是都长一个样。读、算、写三类的安全边界完全不同,这份模板把三类摆在一起:

类型必须做的事为什么
读操作返回值限长、限条数超长结果会把上下文淹掉,模型反而抓不住重点
算操作参数约束写进 schema校验在工具入口完成,脏数据进不了业务;绝不用 eval 跑模型给的表达式
写操作限额 + 幂等 + 人工确认模型会重试,重试就可能重复下单;额度必须在代码里卡死
tool_template.py —— 读 / 算 / 写三类工具的写法边界可复用模板
"""可复用骨架:一个工具该长什么样。

三类工具各有各的写法边界,这份模板把三类都摆出来:
    读操作    放开用,返回值尽量短、尽量结构化
    算操作    参数在 schema 层就校验掉,别把脏数据放进计算
    写操作    自己限额、自己幂等,再配 HumanInTheLoopMiddleware 复核

改 TODO 的地方即可。
"""

from datetime import datetime

from langchain.tools import tool
from pydantic import BaseModel, Field

MAX_ROWS = 50              # TODO 1:读操作的返回上限
MAX_AMOUNT = 10000.0       # TODO 2:写操作的单次额度上限
_DONE: dict[str, str] = {}  # 幂等表:请求号 → 已产生的结果


# ---------- 读操作 ----------------------------------------------------
class SearchInput(BaseModel):
    keyword: str = Field(description="TODO 3:检索关键词,说明从用户的哪句话里取")
    limit: int = Field(default=10, ge=1, le=MAX_ROWS,
                       description=f"返回条数,最多 {MAX_ROWS} 条")


@tool(args_schema=SearchInput)
def search_records(keyword: str, limit: int = 10) -> str:
    """TODO 4:一句话说清这个工具查什么表、什么时候该用、什么时候不该用。"""
    rows = [f"{keyword}-记录{i + 1}" for i in range(min(limit, MAX_ROWS))]
    # 返回值越短,模型越不容易被淹没;超长结果先截断再给它。
    return ";".join(rows) if rows else "没有查到匹配的记录"


# ---------- 算操作 ----------------------------------------------------
class CalcInput(BaseModel):
    amount: float = Field(gt=0, description="金额,必须为正数")
    rate: float = Field(gt=0, le=1, description="折扣系数,0.85 表示八五折")


@tool(args_schema=CalcInput)
def calc_amount(amount: float, rate: float) -> str:
    """按折扣系数计算实付金额。只负责算钱,不查数据也不写库。"""
    # 校验交给 Pydantic,这里只写业务;
    # 千万别用 eval 跑模型给的表达式,那等于把执行权交出去。
    return f"{amount:.2f} × {rate} = {amount * rate:.2f}"


# ---------- 写操作 ----------------------------------------------------
class WriteInput(BaseModel):
    request_id: str = Field(description="业务请求号,同一笔业务重试时必须一致")
    amount: float = Field(gt=0, le=MAX_AMOUNT,
                          description=f"金额,单次不得超过 {MAX_AMOUNT}")
    memo: str = Field(default="", description="备注,可留空")


@tool(args_schema=WriteInput)
def submit_request(request_id: str, amount: float, memo: str = "") -> str:
    """提交一笔付款申请。这是写操作,会真的产生一条记录。
    只有在用户明确确认之后才使用;金额超限时不要拆单绕过上限。"""
    if request_id in _DONE:            # 幂等:同一个请求号只生效一次
        return f"该请求已处理过:{_DONE[request_id]}"
    if amount > MAX_AMOUNT:            # schema 之外再兜一层,防止绕过
        return f"金额 {amount} 超过单次上限 {MAX_AMOUNT},已拒绝"
    result = f"受理成功,单号 RQ{datetime.now():%Y%m%d%H%M%S},金额 {amount:.2f}"
    _DONE[request_id] = result
    return result


TOOLS = [search_records, calc_amount, submit_request]


if __name__ == "__main__":
    print(search_records.invoke({"keyword": "报销", "limit": 3}))
    print(calc_amount.invoke({"amount": 268, "rate": 0.85}))
    print(submit_request.invoke({"request_id": "R-001", "amount": 500}))
    print(submit_request.invoke({"request_id": "R-001", "amount": 500}))
写操作里的幂等,不是可选项 模型失败后会重试,护栏里的异常兜底也鼓励它重试。没有幂等表,一次网络抖动就可能变成两张订单。模板里用「业务请求号 → 已产生的结果」这张表兜住:同一个请求号只生效一次,重复调用返回同一个结果。

5.3 两份模板怎么配合

第一步tool_template.py 写你的工具
第二步把工具列表填进 agent_skeleton.pyTOOLS
第三步watch 跑几个刁钻问题,看它选没选对

第三步不能省。工具写完的第一件事是验描述,不是验功能——功能你自己单测就能验,而「模型会不会在该用的时候用它」只能真跑一遍才知道。发现它该调不调,先改 description,别急着改代码。

06易错点汇总

按「工具定义 / 参数 / 装配 / 循环与流式 / 安全」五类归并,踩过一次就别再踩

⚠️ 一、工具定义

  • description 当注释写。 写成「查订单」三个字,模型就不知道什么时候该用它,该调时不调、直接编一个答案。描述要回答四件事:干什么、何时用、参数从哪取、何时别用。这是本讲铁律,也是排查「工具不被调用」的第一现场。
  • @tool 却不写文档字符串。 装饰器默认拿文档字符串当描述,没有它就等于把工具的说明书撕了。
  • 忘了写类型注解。 def add(a, b) 生成不出像样的参数结构,模型只能瞎猜类型。类型注解是必须的,不是风格问题。
  • 工具名带空格或特殊字符。 推荐小写词加下划线;有些模型服务会直接拒绝带空格的名字,报错信息还很难懂。
  • 一个工具塞进五件事。 「查询并计算并下单」这种万能工具,模型反而不知道什么时候用。一件工具做一件事,这是模块化设计的前提。
  • 工具箱无限膨胀。 工具越多、上下文越长、选错概率越高。超过十来件就分组或者动态挂载。
  • 改函数体却忘了改描述。 函数已经支持按日期查了,描述里还写着「只查当天」——模型会照着描述办事,而不是照着代码。

⚠️ 二、参数与 schema

  • 参数名和 schema 里的字段名对不上。 args_schema 的字段必须和函数形参一一对应,否则调用时参数传不进去。
  • 只写类型不写 description city: str 不告诉模型「用中文城市名」,它就可能填英文;一句话就能省掉的扯皮。
  • 把取值范围写在提示词里而不是 schema 里。 写进 Field(ge=1, le=7) 才是硬约束,由框架在执行前拦下;写在提示词里只是建议。
  • 用了保留的参数名。 configruntime 是框架保留的,拿它们当业务参数名会在运行时报错。要访问运行时信息就用 ToolRuntime
  • 期待 schema 能拦住业务错误。 它只校验类型和取值范围,「这家店有没有这个货」这种业务校验还得你自己写在函数里

⚠️ 三、装配与旧写法迁移

  • 照着旧资料写 initialize_agent + AgentType 这套属于 legacy,已经搬到 langchain-classic,不再是推荐写法。当代写法只有一个入口:from langchain.agents import create_agent
  • langgraph.prebuiltcreate_react_agent 导入路径与函数名都变了,现在是 langchain.agentscreate_agent
  • 还在用 prompt= 传人设。 参数名是 system_prompt=,而且传字符串,不要传消息对象。
  • 调用时传字符串或 {"input": ...} 入参形态是 {"messages": [{"role": "user", "content": "..."}]}agent.run("问题") 也是旧写法。
  • result["output"] 取答案。 返回的是状态字典,答案在 result["messages"][-1]
  • 还在找 pre_model_hook / post_model_hook 这些钩子统一成了 middleware:before_model / after_model / wrap_model_call / wrap_tool_call
  • 沿用 langchain_community.llms.Ollama 换成伙伴包 langchain-ollamalangchain-community 里的老入口不是当代推荐路径。

⚠️ 四、执行循环与流式

  • 流式里按 "agent" 取节点。 模型节点现在叫 "model",工具节点叫 "tools"。按旧名字取不会报错,只会一直取到空,很难排查。
  • 循环没有上限。 模型可能在两个工具之间来回打转,烧钱且不会自己停。before_model 做步数刹车
  • 工具抛异常直接崩掉整个任务。wrap_tool_call 把异常转成消息还给模型,它通常会改参数重试。
  • 工具返回值过长。 一次返回几千行会把上下文淹掉,还可能直接撑爆窗口。返回前先截断、先聚合
  • return_direct=True 当加速开关乱用。 它会让循环当场结束、结果直接甩给用户,模型没有机会再加工。需要后续推理或串联别的工具时不能用。
  • invoke 调试。 它只给最终结果,中间全是黑箱。调试一律用 stream(stream_mode="updates")
  • 忘了并行调用。 模型可能一次返回多条调用意图,框架会并行执行;写日志、算计数时别假设「一步只有一条」。

⚠️ 五、记忆与安全

  • 以为传了 checkpointer 就有记忆。 还得在每次调用时带 thread_id,否则状态存了也找不回来。
  • 所有用户共用一个 thread_id 会话之间本该互相隔离,共用就会串上下文,把 A 的订单说给 B 听。
  • InMemorySaver 上生产。 它只存在内存里,进程一退全没。要跨进程留存就换数据库支撑的实现。
  • 用了人工确认却没配 checkpointer 中断之后状态没地方存,恢复不回来。两者必须成对出现。
  • 写操作只靠提示词约束。 「库存不足不要下单」写在人设里只是建议。限额、幂等、库存校验必须落在函数代码里。
  • 把数据库口令、API 密钥硬编码进源码。 一律 os.environ.get(...).env 不进版本库。
  • 给模型一个能执行任意代码的工具。 让它写表达式再 eval、或者直接开一个 Shell 工具,等于把执行权交出去。真要做,必须配沙箱、白名单与超时。
  • 忘了超时。 工具里一个卡死的 HTTP 请求会把整个 Agent 挂住;对外请求一律设超时。

07自测题

点击题目展开答案;能把这 16 题说清楚,这一讲就通了

一、Tool 的四要素
一件 Tool 由哪四个要素组成?其中哪些是模型看得见的?

名称name)、功能描述description)、参数结构args_schema)、要调用的函数(函数本体)。前三样会被送进模型的上下文,第四样只在你的进程里执行——模型自始至终没碰过那段代码。

@tool 装饰一个函数时,工具的名称和描述默认从哪里来?怎么覆盖?

名称默认取函数名,描述默认取函数的文档字符串——所以用 @tool 时文档字符串是必需的。覆盖方式:@tool("add_two_number", description="计算两个整数的和"),第一个位置参数就是工具名。

为什么工具函数必须写类型注解?

因为类型注解决定参数结构a: int 会变成参数表里的 integer。没有注解,模型就不知道该填什么类型,填错的概率大幅上升。这不是代码风格问题,是功能问题。

return_direct=True 是什么意思?什么时候不该用?

默认 False,工具结果会回到模型手里、由它决定下一步;设成 True 则执行完直接把结果返回给用户、循环当场中断。当结果还需要模型进一步推理、总结,或者还要串联别的工具时,不能用它——因为模型根本没机会看到这个结果。

二、description 与参数
本讲铁律是什么?把 description 写成「查订单」会有什么后果?

铁律:工具的 description 是写给模型看的,不是写给同事看的注释;模型选不选你这个工具,全看它。
写成「查订单」三个字,模型不知道什么时候该用它,典型后果是该调的时候不调、直接编一个答案,或者在多工具场景里选错工具。

一句合格的工具描述要回答哪四个问题?

① 它干什么(不写则永远不被选中)、② 什么时候该用(不写则该调不调)、③ 参数从哪儿取(不写则参数填错)、④ 什么时候别用(不写则在多工具场景里抢别人的活)。

@toolStructuredTool.from_function 各适合什么场景?

@tool 适合你自己新写的函数,一行装饰器最省事。StructuredTool.from_function 适合函数来自别人的模块或 SDK、不方便改的情况,在外面包一层补齐名称与描述;它还能同时挂同步实现(func)与异步实现(coroutine),也便于在运行时按配置批量生成工具。

把参数的取值范围写在 system_prompt 里和写在 Field(ge=1, le=7) 里有什么区别?

写在提示词里只是建议,模型可以不遵守;写进 Field硬约束,由 Pydantic 在工具执行前校验,越界直接抛异常,轮不到你的业务代码处理脏数据。凡是能表达成约束的,都该写进 schema。

三、create_agent 与执行循环
create_agent 搭一个 Agent 需要哪几步?入参和返回值各是什么形态?

四步:定义工具 → create_agent(model=..., tools=[...], system_prompt="...")invoke → 读结果。
入参是字典 {"messages": [{"role": "user", "content": "..."}]};返回也是状态字典,完整消息链在 result["messages"],给用户看的答复是最后一条

案例里用户一句话问了「还有多少货」和「多少钱」两件事,先查后算的顺序是谁定的?

模型定的。代码里只把三件工具交出去,没有任何一行写「先调查询再调计算」。模型看懂第二件事要用第一件的结果,于是自己排了顺序、分两轮委托。排序的判断权在模型,执行的动作在框架和你的代码里。

调试 Agent 时为什么不该用 invoke?流式的节点名要注意什么?

invoke 只给最终结果,中间调了哪些工具、转了几圈全是黑箱。调试用 stream(stream_mode="updates"),每个节点跑完就吐一块更新。
注意模型节点叫 "model",工具节点叫 "tools"。旧写法里模型节点叫 "agent",按旧名字取不会报错,只会一直取到空

怎么让 Agent 记住上一轮说过的话?两个必需条件是什么?

create_agent 传一个 checkpointer(负责把状态持久化),调用时带上 thread_id(负责区分是哪一个会话)。两者缺一不可——只传 checkpointer 不带 thread_id,状态存了也找不回来。不同 thread 之间完全隔离。

四、迁移、护栏与安全
三代写法各是什么?迁移时最容易漏的三个参数变化是什么?

第一代 initialize_agent + AgentType 枚举(提示词内置、改不动);第二代 create_react_agent + AgentExecutor(决策与执行分离、提示词可自定义);第三代 create_agent 统一入口。
最容易漏的三处:导入路径 langgraph.prebuiltlangchain.agentsprompt=system_prompt=(传字符串);流式节点名 "agent""model"

middleware 有哪几个常用钩子?异常兜底和步数刹车分别挂在哪个上?

before_agent / before_model / wrap_model_call / wrap_tool_call / after_model / after_agent
工具异常兜底wrap_tool_call:把异常转成一条消息还给模型,让它改参数重试,而不是让整个任务崩掉。步数刹车before_model:统计已经调了多少次模型,超限就强行收尾。

要让「下单」这类写操作在执行前由人确认,需要配哪些东西?漏了会怎样?

HumanInTheLoopMiddleware,把要拦的工具名写进 interrupt_on;同时必须配 checkpointer,并且每次调用带同一个 thread_id。漏了 checkpointer,中断之后状态没地方存,恢复不回来。恢复时可以 accept(照原样执行)、edit(改参数再执行)或 reject(拒绝并说明原因)。

写操作类工具除了人工确认,代码里还必须做哪两件事?为什么?

限额幂等。限额要在函数里卡死,不能只写在描述和人设里——提示词只是建议,模型可能填一个超量参数。幂等要靠「业务请求号 → 已产生的结果」这张表:模型失败后会重试,护栏里的异常兜底还鼓励它重试,没有幂等,一次网络抖动就可能变成两张订单。另外别忘了给对外请求设超时。

术语表

术语含义
Tool被包装过的普通函数,带名称、描述、参数结构;是 Agent 与外部世界交互的接口
@tool把函数变成 Tool 的装饰器;默认取函数名作名称、取文档字符串作描述
StructuredTool另一种造工具的方式,from_function 把已有函数包成 Tool,可同时挂同步与异步实现
description写给模型看的功能描述,模型选不选这件工具的唯一依据
args_schema用 Pydantic 写的参数结构,描述每个参数的含义、类型与取值范围,并在执行前校验
return_direct仅对 Agent 生效;为 True 时工具执行完直接把结果返回用户、循环中断
create_agentlangchain.agents 里构建 Agent 的当代入口,装配模型、工具、人设与 middleware
system_promptcreate_agent 的人设参数,传字符串;旧写法里叫 prompt
执行循环调模型 → 判断有无工具调用 → 执行工具 → 回填 → 再调模型,直到不再需要工具
节点名流式输出里的节点标识:模型节点是 "model",工具节点是 "tools"
middleware插在执行循环缝隙里的钩子:before_model / wrap_model_call / wrap_tool_call / after_model
HumanInTheLoopMiddleware内置中间件,把指定工具拦在执行之前交给人确认;必须配 checkpointer
SummarizationMiddleware内置中间件,历史过长时自动做摘要压缩,避免撑爆上下文窗口
checkpointer状态持久化组件,Agent 侧会话记忆靠它存;InMemorySaver 是内存实现
thread_id会话标识,放在 config["configurable"] 里;不同 thread 之间状态完全隔离
response_format让 Agent 产出结构化结果的参数,取值用 ToolStrategyProviderStrategy
ToolStrategy把输出 schema 当成一件工具交给模型填,适用于任何支持工具调用的模型
langchain-classiclegacy 功能的新家,initialize_agentLLMChain 这类旧写法搬到了这里
✅ 一句话收束本讲 做 Agent 的工程量,八成花在把工具的说明卡写清楚把护栏立起来这两件事上。循环框架替你转,模型替你想,而「它能不能挑对工具」这件事,从头到尾是你写出来的