SERIES · Langchain Agent 应用开发

如何写好 LangChain Tools:不是会调函数就够了,而是怎么把工具真正接进工程系统

2026-03-19 · 30 min read · by GUMP

如何写好 LangChain Tools:不是会调函数就够了,而是怎么把工具真正接进工程系统

如果你要做一个可上线的 AI 助手,LangChain 的 Tools 到底应该怎么设计?

很多人第一次看 LangChain 的 Tools,会把它理解成“给大模型多挂几个函数”。

但如果你真的要把 Agent 放进生产环境,Tools 绝不只是“函数调用封装”这么简单。它实际上是 Agent 和外部系统之间的协议层:负责定义输入输出、控制状态读写、连接长期记忆、处理错误,甚至参与整个 LangGraph 工作流的路由。官方文档明确把 Tools 定义为扩展 Agent 能力的机制,让模型能够获取实时数据、执行代码、访问数据库并执行现实世界中的动作;其底层本质是“具有明确输入输出的可调用函数”,这些函数会被传给聊天模型,由模型决定何时调用以及传入什么参数。(LangChain Docs)

一、先别急着写工具:你要先理解 Tool 在工程里的位置

官方文档里最重要的一句话,不是 @tool 怎么写,而是 Tools 在 Agent 体系中的角色:模型负责决策,Tool 负责执行。Tool 本身不是智能体,它只是一个受控、可描述、可验证的执行单元。(LangChain Docs)

这意味着在工程里,Tool 设计的重点不是“能不能跑”,而是下面这几个问题:

  1. 模型能不能正确理解什么时候该调用它
  2. 模型能不能准确传参
  3. Tool 返回的数据,能不能让后续推理继续进行
  4. Tool 出错时,系统会不会崩
  5. Tool 是否会影响对话状态、用户上下文和长期记忆

所以,Tools 设计得好不好,直接决定了 Agent 是否“可控”。


二、最基础的 @tool,真正关键的不是装饰器,而是 schema

官方给出的最简单写法是用 @tool 装饰函数;函数的 docstring 默认会变成工具描述,而类型注解会定义工具输入 schema。文档还特别强调:type hints 是必需的,因为它们决定了工具的输入结构。(LangChain Docs)

最小示例可以写成这样:

python
from langchain.tools import tool
@tool
def search_orders(order_id: str) -> str:
"""Query order information by order ID."""
return f"Order {order_id} is in transit."

很多人会觉得这很简单,但工程上最容易踩坑的恰恰是这里。

工程实践建议 1:把 Tool 当成“面向模型的 API”

你写 Tool,不是在给人类同事写函数,而是在给 LLM 暴露 API。

所以这三样东西比实现本身更重要:

  • 函数名:模型怎么识别它
  • 参数名 + 类型:模型怎么构造调用参数
  • docstring / description:模型什么时候选择它

官方文档建议工具名优先使用 snake_case,避免空格和特殊字符,因为有些模型提供方会拒绝包含这些字符的工具名。(LangChain Docs)

所以,别写这种:

python
@tool("Get Order Info!!!")
def x(id: str) -> str:
...

更稳妥的是:

python
@tool("get_order_info")
def get_order_info(order_id: str) -> str:
"""Get order status by order ID."""
...

这不是“代码风格问题”,而是跨模型兼容性问题。(LangChain Docs)


三、描述写得越“像产品说明书”,模型越不容易乱用

官方文档支持自定义工具名和 description。默认情况下,docstring 会变成工具描述;如果你需要更明确地告诉模型何时调用,可以手动写 description。(LangChain Docs)

例如:

python
from langchain.tools import tool
@tool(
"refund_lookup",
description="Look up refund status for an existing paid order. "
"Use this only when the user asks about refund progress or refund eligibility."
)
def refund_lookup(order_id: str) -> str:
"""Query refund record by order ID."""
...

工程实践建议 2:description 要回答“什么时候用”,不是“它是什么”

很多团队写 description 时,只写“查询退款”。这对人类够了,对模型不够。

你更应该告诉模型:

  • 适用场景:什么时候调用
  • 不适用场景:什么时候不要调用
  • 参数语义:参数到底是什么
  • 输出边界:返回的是摘要还是原始数据

因为模型的错误,不少时候不是“调不动工具”,而是“调错工具”。


四、真实业务里,简单参数远远不够:要上 Pydantic Schema

官方文档支持用 Pydantic 模型或 JSON Schema 定义复杂输入,其中示例重点展示了 args_schema=WeatherInput 的方式。这样你可以为字段增加类型约束、枚举值和描述。(LangChain Docs)

这在工程里非常关键,因为真实 Tool 往往不是一个字符串参数,而是一组结构化输入。

比如一个客服工单查询工具:

python
from typing import Literal
from pydantic import BaseModel, Field
from langchain.tools import tool
class TicketQuery(BaseModel):
user_id: str = Field(description="Unique user identifier in CRM")
ticket_type: Literal["refund", "shipment", "invoice"] = Field(
description="Type of support ticket"
)
include_history: bool = Field(
default=False,
description="Whether to include previous related tickets"
)
@tool(args_schema=TicketQuery)
def query_ticket(user_id: str, ticket_type: str, include_history: bool = False) -> dict:
"""Query customer support ticket data."""
return {
"user_id": user_id,
"ticket_type": ticket_type,
"latest_status": "processing",
"history_included": include_history,
}

为什么这比普通函数签名更适合生产

因为它能把模型调用行为约束在你允许的空间里:

  • ticket_type 只能是固定枚举
  • 字段有明确描述,模型不容易猜错
  • 输出可以做结构化消费

官方文档给出的结论也很明确:复杂输入可以用 Pydantic 或 JSON Schema 定义。(LangChain Docs)


五、很多人不知道:configruntime 不能拿来当普通参数名

这是一个很容易在项目里踩的坑。官方文档明确列出了保留参数名:configruntime。这两个名字不能作为普通工具参数使用,否则会导致运行时错误。原因是它们被 LangChain 内部用于传递 RunnableConfigToolRuntime。(LangChain Docs)

也就是说,下面这种写法是危险的:

python
@tool
def query_user(runtime: str) -> str:
...

如果你真要访问运行时信息,应该显式接收 ToolRuntime。(LangChain Docs)


六、真正让 Tool 从“函数”升级成“系统组件”的,是 ToolRuntime

官方文档里最有工程价值的部分,其实是 ToolRuntime

它让 Tool 不只是一个无状态函数,而是能访问运行时环境,包括:

  • State:当前会话里的短期状态
  • Context:调用时传入的不可变上下文
  • Store:跨会话持久化存储
  • Stream Writer:执行过程中的实时流式输出
  • Config:执行配置
  • Tool Call ID:当前工具调用的唯一标识

这些能力都是通过 runtime: ToolRuntime 暴露给 Tool 的。(LangChain Docs)

这套设计很重要,因为它把 Tool 从“函数工具箱”变成了“有上下文的业务执行点”。


七、短期状态:适合放“本轮对话里会变”的信息

文档把 State 定义为短期记忆,覆盖当前会话期间存在的数据,包括消息历史和你定义的自定义字段。Tool 可以通过 runtime.state 访问这些信息。(LangChain Docs)

典型场景:客服会话里的多轮澄清

比如用户说:

帮我查一下订单状态

系统先问:

请提供订单号

用户接着说:

A20260318001

这时候后续 Tool 就可以从消息历史或自定义状态里取信息,而不是每次都重新解析整段上下文。

示例:

python
from langchain.tools import tool, ToolRuntime
from langchain.messages import HumanMessage
@tool
def get_last_user_message(runtime: ToolRuntime) -> str:
"""Get the most recent user message."""
for msg in reversed(runtime.state["messages"]):
if isinstance(msg, HumanMessage):
return msg.content
return "No user message found"

这类能力在“多轮收集参数”的业务流程里很好用。官方文档也明确说明:runtime 参数会被自动注入,并且不会出现在模型可见的工具 schema 里。也就是说,模型只会看到真正需要它填写的业务参数。(LangChain Docs)


八、状态更新:不是 return 字符串,而是 return Command

如果 Tool 只是查数据,返回字符串或对象就够了。

但如果 Tool 需要改状态,例如“记录用户语言偏好”“保存用户姓名”“设置业务流程阶段”,官方文档建议返回 Command(update=...) 来更新状态。(LangChain Docs)

例如:

python
from langgraph.types import Command
from langchain.tools import tool
@tool
def set_user_name(new_name: str) -> Command:
"""Set the user's name in state."""
return Command(update={"user_name": new_name})

这在工程里意味着什么

这意味着 Tool 不只是“做事”,还可以“改系统状态”。

比如你做一个企业知识助手,用户第一次说:

以后请默认用中文回答

你不应该只返回一句“好的”。

更好的做法是:

  1. Tool 更新 preferred_language = zh-CN
  2. 后续流程自动读取这个状态
  3. 之后所有回答默认用中文

官方文档还提醒了一个非常工程化的问题:如果多个 Tool 可能并发更新同一个状态字段,要考虑 reducer,否则并发写入时可能发生冲突。(LangChain Docs)

这说明 LangChain 官方其实已经把“并发状态一致性”这个生产问题摆到桌面上了。


九、Context:适合放用户身份、租户信息、会话配置,而不是临时变量

文档把 Context 定义为调用时传入的不可变配置数据,例如 user ID、session 信息或应用配置,Tool 可以通过 runtime.context 访问。官方示例用 dataclass 定义了 UserContext,再通过 context_schema 传给 agent。(LangChain Docs)

这在多租户系统里非常重要。

一个常见反模式

不少项目会把 user_id 暴露成 Tool 参数,让模型自己填。

这其实不安全,因为 user_id 属于系统侧可信上下文,不应该交给模型生成。

更合理的方式是:

  • user_id 放进 context
  • Tool 从 runtime.context.user_id 读取
  • 模型只负责填写业务参数,比如订单号、时间范围、查询类型

示例:

python
from dataclasses import dataclass
from langchain.tools import tool, ToolRuntime
@dataclass
class AppContext:
user_id: str
tenant_id: str
@tool
def get_my_profile(runtime: ToolRuntime[AppContext]) -> str:
"""Get current user's profile."""
user_id = runtime.context.user_id
tenant_id = runtime.context.tenant_id
return f"user={user_id}, tenant={tenant_id}"

工程价值

这样做能明显降低两个风险:

  • 模型伪造身份参数
  • 不同租户数据串读

十、长期记忆 Store:让 Tool 真正具备“跨会话连续性”

官方文档把 BaseStore 定义为可跨会话持久化的数据存储,并通过 runtime.store 暴露给 Tool;同时明确建议生产环境使用持久化存储实现,比如 PostgresStore,而不是 InMemoryStore。(LangChain Docs)

这部分非常适合做“用户偏好”和“长期画像”。

例如:

python
from typing import Any
from langchain.tools import tool, ToolRuntime
@tool
def save_user_preference(user_id: str, preference: dict[str, Any], runtime: ToolRuntime) -> str:
"""Save user preference."""
runtime.store.put(("users", "preferences"), user_id, preference)
return "Preference saved."
@tool
def get_user_preference(user_id: str, runtime: ToolRuntime) -> str:
"""Get user preference."""
item = runtime.store.get(("users", "preferences"), user_id)
return str(item.value) if item else "No preference found"

什么时候该用 State,什么时候该用 Store?

结合官方定义,可以简单理解为:

  • State:只对当前会话有效
  • Store:跨会话长期保留 (LangChain Docs)

真实工程建议

适合放进 Store 的有:

  • 用户常用语言
  • 默认展示时区
  • 常查项目 / 常用筛选条件
  • 企业助手的个性化偏好

不适合放进去的有:

  • 当前轮临时槽位
  • 本轮上下文里的中间推理结果
  • 一次性 API 返回值缓存

十一、长耗时工具不要“闷头跑”,要流式汇报进度

官方文档提供了 runtime.stream_writer,允许 Tool 在执行过程中发出实时更新,适合长时间运行的任务。文档同时说明:如果在 Tool 中使用 runtime.stream_writer,这个 Tool 必须运行在 LangGraph 执行上下文里。(LangChain Docs)

这个能力在生产里非常重要,尤其是以下场景:

  • 调多个外部 API
  • 执行复杂 ETL
  • 跑检索 + 重排 + 汇总
  • 批量处理文件

示例:

python
from langchain.tools import tool, ToolRuntime
import time
@tool
def generate_monthly_report(month: str, runtime: ToolRuntime) -> str:
"""Generate monthly business report."""
writer = runtime.stream_writer
writer(f"Start generating report for {month}")
time.sleep(1)
writer("Loading sales data")
time.sleep(1)
writer("Aggregating KPI metrics")
time.sleep(1)
writer("Finalizing report")
return f"Monthly report for {month} is ready."

工程收益

这不是“体验优化”那么简单。它能减少用户误以为系统卡死,也能让前端更容易做可观测性展示。


十二、ToolNode:真正做复杂工作流时,不要只会 create_agent

文档明确说,ToolNode 是 LangGraph 里的预构建节点,负责执行工具,并自动处理并行执行、错误处理和状态注入;如果你要做定制工作流、需要对工具执行模式有更细粒度控制,应该使用 ToolNode,而不是只依赖 create_agent。(LangChain Docs)

这其实是在告诉你:

  • 简单 Agentcreate_agent
  • 复杂工作流ToolNode + StateGraph

为什么工程里常常要上 ToolNode

因为真实系统往往不是“模型说一句,工具调一次”这么简单,而是:

  1. 模型先判断是否需要工具
  2. 如果需要,进入工具节点
  3. 工具执行后再回到模型
  4. 某些分支失败时要进入兜底流程
  5. 某些情况下直接结束,不再回到模型

官方文档里的 tools_condition 就是为这种路由提供支持:它能根据 LLM 是否发起了工具调用,把流程分流到 toolsEND。(LangChain Docs)

一个常见图可以抽象成这样:

python
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.graph import StateGraph, MessagesState, START, END
builder = StateGraph(MessagesState)
builder.add_node("llm", call_llm)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "llm")
builder.add_conditional_edges("llm", tools_condition)
builder.add_edge("tools", "llm")
graph = builder.compile()

适合 ToolNode 的业务

  • 企业问答 + 多工具编排
  • 客服自动化流程
  • 带状态推进的业务助理
  • 有明确“调用工具 / 不调用工具”分支的系统

十三、Tool 返回什么,不是编码习惯问题,而是推理接口设计问题

官方文档把 Tool 返回值分成三类:

这三类在工程上分别对应三种完全不同的用途。

1)返回 string:给模型“读”

适合人类可读结果,例如:

python
@tool
def get_weather(city: str) -> str:
"""Get weather for a city."""
return f"It is currently sunny in {city}."

官方文档说明,这类返回值会被转换成 ToolMessage,再交给模型继续处理。(LangChain Docs)

适合场景:

  • 搜索摘要
  • 简单查询结果
  • 简单执行回执

2)返回 object:给模型“解析”

适合结构化数据,例如:

python
@tool
def get_weather_data(city: str) -> dict:
"""Get structured weather data."""
return {
"city": city,
"temperature_c": 22,
"conditions": "sunny",
}

官方文档说明,object 会被序列化后作为工具输出,让模型读取具体字段。(LangChain Docs)

适合场景:

  • 查询订单详情
  • 检索命中列表
  • 指标聚合结果
  • 需要多字段推理的中间数据

3)返回 Command:给系统“改状态”

适合那些不仅要返回结果,还要更新系统状态的 Tool。官方文档还特别提到:如果模型需要看到“工具执行成功”的反馈,可以在 Command 里附带 ToolMessage,并使用 runtime.tool_call_id 作为 tool_call_id。(LangChain Docs)

这在“写状态 + 给用户确认”的流程里很实用。


十四、错误处理别留给异常栈,应该设计成产品能力

官方文档里,ToolNode 支持多种错误处理方式:

  • 默认行为
  • handle_tool_errors=True:捕获错误并把错误信息返回给 LLM
  • 自定义固定错误消息
  • 自定义错误处理函数
  • 只捕获指定异常类型 (LangChain Docs)

这意味着在生产环境里,Tool 的异常处理可以是系统化设计,而不是简单 try/except

例如:

python
from langgraph.prebuilt import ToolNode
def handle_error(e: ValueError) -> str:
return f"Invalid input: {e}"
tool_node = ToolNode(tools, handle_tool_errors=handle_error)

工程建议

按错误来源分层处理:

  • 参数错误:返回明确可纠正提示
  • 权限错误:提示无权访问,不暴露内部细节
  • 外部服务错误:给用户兜底话术,并记录日志
  • 系统异常:不要原样抛给用户

官方提供的接口,已经足够把这些策略做成统一层。(LangChain Docs)


十五、一个更接近真实项目的 Tool 设计方案

下面我给你一个“企业内部助手”的 Tool 设计模板,思路完全基于这页文档能力组合而来。

目标

做一个“员工自助助手”,支持:

  • 查询个人资料
  • 查询报销状态
  • 设置默认语言
  • 保存个人偏好
  • 长耗时任务显示进度

工具分层建议

1. 查询类 Tool:返回 object

python
@tool
def get_expense_status(expense_id: str) -> dict:
"""Get expense reimbursement status by expense ID."""
return {
"expense_id": expense_id,
"status": "under_review",
"amount": 1280.0,
"currency": "CNY"
}

2. 用户身份相关:从 context 拿,不让模型传

python
from dataclasses import dataclass
from langchain.tools import ToolRuntime
@dataclass
class EmployeeContext:
user_id: str
department: str

3. 偏好设置:返回 Command 更新 state

python
from langgraph.types import Command
from langchain.messages import ToolMessage
@tool
def set_language(language: str, runtime: ToolRuntime) -> Command:
"""Set preferred reply language."""
return Command(
update={
"preferred_language": language,
"messages": [
ToolMessage(
content=f"Preferred language updated to {language}.",
tool_call_id=runtime.tool_call_id,
)
]
}
)

4. 长期偏好:写入 store

python
@tool
def save_dashboard_pref(pref: dict, runtime: ToolRuntime) -> str:
"""Save dashboard preferences."""
user_id = runtime.context.user_id
runtime.store.put(("preferences",), user_id, pref)
return "Dashboard preference saved."

5. 报表生成:用 stream_writer

python
@tool
def build_department_report(runtime: ToolRuntime) -> str:
"""Build department report."""
runtime.stream_writer("Loading department data")
runtime.stream_writer("Computing monthly metrics")
runtime.stream_writer("Packaging final report")
return "Department report generated."

这个方案为什么靠谱

因为它把官方文档里的四类运行时能力分工得很清楚:

  • 会话内临时信息 → state
  • 调用侧身份配置 → context
  • 跨会话持久化 → store
  • 长任务反馈 → stream_writer (LangChain Docs)

这比“把一切都塞进一个万能 Tool 里”要稳定得多。


十六、什么时候该自己写 Tool,什么时候该用预构建 Tool

官方文档提到,LangChain 提供了大量预构建工具和 toolkits,可用于网页搜索、代码解释、数据库访问等常见场景;同时也提到,一些聊天模型本身支持服务端工具能力,比如 web search 和 code interpreter,这类能力由模型提供方在服务端执行,不需要你自己定义或托管工具逻辑。(LangChain Docs)

对工程团队来说,可以这样决策:

优先用预构建 / 服务端工具的场景

  • 通用能力,没明显业务壁垒
  • 快速验证 PoC
  • 不想自己维护执行环境

优先自定义 Tool 的场景

  • 涉及业务系统权限
  • 需要严格控制输入输出
  • 需要读写状态 / 上下文 / 长期记忆
  • 需要做异常治理和审计日志

换句话说:

“会不会写 Tool”不重要,重要的是你是否知道哪些能力应该掌握在自己系统里。


十七、我对这页文档的一个总结:它讲的不是工具调用,而是 Agent 的工程边界

如果只看表面,这页文档像是在讲:

  • 如何写 @tool
  • 如何自定义 name / description
  • 如何定义 schema
  • 如何用 ToolNode

但如果从工程角度读,它其实在回答一个更关键的问题:

Agent 到底怎样才能安全地接入真实系统?

官方给出的答案,基本都在这页里了:

所以,真正成熟的 Tool 设计思路应该是:

Tool = 面向模型的受控 API + 面向系统的状态接口 + 面向生产的执行单元


十八、落地时最值得记住的 8 条经验

最后我把这页文档提炼成 8 条工程经验,方便你直接放进博客结尾:

  1. Tool 名称和描述不是装饰,是模型决策接口。 官方建议使用 snake_case,避免特殊字符,提高兼容性。(LangChain Docs)
  2. 复杂参数一定要 schema 化。 Pydantic/JSON Schema 能显著降低模型乱传参。(LangChain Docs)
  3. runtimeconfig 不能当普通参数名。 这是官方保留字段。(LangChain Docs)
  4. 短期会话数据放 state,跨会话数据放 store。 两者职责要分清。(LangChain Docs)
  5. 用户身份这类可信信息放 context,不要让模型生成。 官方支持通过 context_schema 注入不可变上下文。(LangChain Docs)
  6. 需要改系统状态时,用 Command,不要硬塞文本返回。 (LangChain Docs)
  7. 复杂流程优先用 ToolNode 和条件路由。 这比单纯 create_agent 更适合生产工作流。(LangChain Docs)
  8. 错误处理要产品化。 ToolNode 已经给出了统一异常治理入口。(LangChain Docs)