前面四章讲的是「能做什么」,这一章讲「怎么做才不会在生产环境翻车」。全部来自真实项目的经验教训,可以直接当 Code Review 清单用。
1 工具设计十诫
第 1 诫 · 名字要「动词开头 + snake_case」
# ✗ 反例
@tool("Search Tool") # 有空格,跨厂商兼容性差
def s(q: str) -> str: ...
# ✓ 正例
@tool("web_search")
def web_search(query: str) -> str: ...
第 2 诫 · description 是给模型看的,不是给人看的
这是投入产出比最高的一条。同样的工具实现,只改描述,准确率可能差 20 个点。
# ✗ 反例:信息量为零
@tool
def query_order(order_id: str) -> str:
"""查询订单。"""
...
# ✓ 正例:说清用途、边界、参数格式、返回形态
@tool("query_order", description=(
"Query a single order by its order id and return status, amount and shipping info.\n"
"Use this when the user asks about a specific order's status, price or logistics.\n"
"Do NOT use this to search orders by date or customer name - use search_orders instead.\n"
"Args: order_id must match pattern SO + 8 digits, e.g. SO20260829.\n"
"Returns: 'NOT_FOUND' if the order does not exist or is not accessible."
))
def query_order(order_id: str) -> str:
"""Query a single order by its order id."""
...
第 3 诫 · 一个工具只做一件事
把「查订单」「改订单」「取消订单」合成一个 manage_order(action=...) 看起来省事,实际会让模型在参数上犯错,而且你无法对不同动作做差异化权限控制。拆成独立工具,再用 middleware 按角色裁剪。
第 4 诫 · 参数要扁平、要枚举、要有默认值
from typing import Literal
from pydantic import BaseModel, Field
# ✗ 反例:嵌套结构 + 自由字符串,模型极易填错
@tool
def search(payload: dict) -> str: ...
# ✓ 正例:扁平字段 + Literal 枚举 + 默认值 + 字段级说明
class SearchInput(BaseModel):
keyword: str = Field(description="搜索关键词,不要带引号或通配符")
status: Literal["paid", "unpaid", "refunded"] = Field(
default="paid", description="订单状态过滤"
)
date_from: str = Field(description="起始日期,格式 YYYY-MM-DD")
date_to: str = Field(description="结束日期,格式 YYYY-MM-DD")
limit: int = Field(default=20, ge=1, le=100, description="返回条数上限")
@tool(args_schema=SearchInput)
def search_orders(keyword: str, status: str = "paid",
date_from: str = "", date_to: str = "", limit: int = 20) -> str: ...
第 5 诫 · 工具数量是准确率的第一杀手

第 6 诫 · 返回值要短、要结构化、要可控
# ✗ 反例:把 500 条记录原样塞回去
@tool
def list_recent_orders(limit: int = 20) -> str:
rows = db.query("SELECT * FROM orders ORDER BY created_at DESC LIMIT ?", limit)
return json.dumps(rows, ensure_ascii=False) # 可能几万字符
# ✓ 正例:精简字段 + 截断 + 给出总量提示
@tool
def list_recent_orders(limit: int = 20) -> str:
"""List recent orders with key fields only."""
rows = db.query("SELECT id, status, amount, created_at FROM orders ORDER BY created_at DESC LIMIT ?", limit)
total = db.query_scalar("SELECT COUNT(*) FROM orders")
lines = [f"{r['id']} | {r['status']} | ¥{r['amount']} | {r['created_at']}" for r in rows]
text = "\n".join(lines)
if len(text) > 3000: # 硬截断,保护上下文
text = text[:3000] + "\n...(已截断,请缩小 limit 或增加过滤条件)"
return f"共 {total} 条,以下为最近 {len(rows)} 条:\n{text}"
第 7 诫 · 错误信息要「模型能改」
# ✗ 反例:模型看到后只能原样重试,然后再次失败
raise ValueError("Invalid input")
# ✓ 正例:指出问题 + 给出正确格式 + 给出示例
raise ValueError(
"参数 date_from 格式错误,收到 '2026/08/29',"
"应为 YYYY-MM-DD(例如 2026-08-29)。请修正后重新调用本工具。"
)
第 8 诫 · 工具必须幂等、有超时、能重试
- 幂等:模型重试、中间件重试、用户重发,都可能让同一个工具被调用多次。写操作要带幂等键(如
request_id),避免重复下单。 - 超时:所有外部调用都要设 timeout,否则一个挂死的 HTTP 请求会拖垮整条链路。参考:
ModelRetryMiddleware/ToolRetryMiddleware。 - 重试:区分可重试(超时、5xx、限流)与不可重试(参数错误、权限不足),后者重试只是浪费钱。
import hashlib
import time
from langchain.tools import ToolRuntime, tool
IDEMPOTENCY = {} # 生产请用 Redis,带 TTL
@tool
def create_ticket(user_id: str, title: str, runtime: ToolRuntime) -> str:
"""Create a support ticket. 幂等:相同输入重复调用只会创建一张工单。"""
# ① 幂等键:由「调用语义」而非时间戳生成
key = hashlib.sha256(f"{runtime.context.user_id}:{title}".encode()).hexdigest()
if key in IDEMPOTENCY:
return f"工单已存在(幂等命中):{IDEMPOTENCY[key]}"
# ② 超时 + 有限重试(只重试可恢复错误)
last_err = None
for attempt in range(3):
try:
ticket_id = ticket_api.create(user_id, title, timeout=8) # 超时 8s
IDEMPOTENCY[key] = ticket_id
return f"工单已创建:{ticket_id}"
except TimeoutError as e: # 可重试
last_err = e
time.sleep(2 ** attempt)
except PermissionError as e: # 不可重试,立即向上抛
raise ValueError(f"无权限创建工单:{e}") from e
raise RuntimeError(f"创建工单失败,已重试 3 次:{last_err}")
第 9 诫 · 身份与权限绝不交给模型
已在 2.5 节强调过,这里再强调一次:user_id、tenant_id、role 一律走 runtime.context 注入。模型产出的任何身份信息都应视为「不可信输入」,只能用于查询参数,绝不能用于鉴权判定。
第 10 诫 · 按副作用分级,高风险动作要人工审批
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware, PIIMiddleware
agent = create_agent(
"gpt-5.5",
tools=[read_data, write_file, send_email, delete_data],
middleware=[
# 高风险动作执行前中断,等人批准
HumanInTheLoopMiddleware(interrupt_on={
"write_file": True,
"send_email": True,
"delete_data": {"allowed_decisions": ["approve", "reject"]},
}),
# 自动识别并脱敏邮箱
PIIMiddleware("email"),
],
)
副作用分级建议
- L0 只读(查询、搜索、计算)→ 全自动,可并行
- L1 写自己的数据(创建草稿、保存偏好)→ 全自动 + 审计日志
- L2 影响他人/外部(发邮件、下单、改权限)→ 必须人工审批
- L3 不可逆(删除、转账、发布)→ 人工审批 + 二次确认 + 完整留痕
2. 性能:并行、异步与缓存

import asyncio
import time
from langchain.tools import tool
_CACHE: dict[tuple, tuple[float, str]] = {}
TTL = 300
@tool
async def get_exchange_rate(base: str, target: str) -> str:
"""Get the exchange rate between two currencies, e.g. base='USD', target='CNY'."""
key = (base.upper(), target.upper())
hit = _CACHE.get(key)
if hit and time.time() - hit[0] < TTL:
return hit[1] + "(缓存)"
rate = await fetch_rate(*key) # 异步 I/O,并行调用时真正并发
_CACHE[key] = (time.time(), rate)
return rate
# 并行执行多个异步工具
results = await asyncio.gather(
get_exchange_rate.ainvoke({"base": "USD", "target": "CNY"}),
get_exchange_rate.ainvoke({"base": "EUR", "target": "CNY"}),
3. 安全与治理
| 风险 | 典型场景 | 对策 |
|---|---|---|
| 任意代码执行 | eval(expression) 实现的计算器工具 | 换成受限求值器(如 asteval)或沙箱执行;绝对不要把用户输入直接 eval / exec。 |
| 注入攻击 | SQL 拼接、URL 拼接、命令拼接 | 一律参数化查询;URL 用白名单域名;禁止拼接 shell 命令。 |
| 越权访问 | 模型构造他人 user_id | 身份走 context;工具内部再做一次归属校验(永远不要相信入参里的身份)。 |
| Prompt Injection | 工具返回的网页内容里藏了「忽略之前的指令」 | 工具返回值做清洗/转义;高风险动作必须人工审批;不要给 Agent 过高的权限。 |
| PII 泄露 | 身份证、手机号进入日志或第三方模型 | PIIMiddleware("email") 等中间件脱敏;日志脱敏;敏感字段不出境。 |
| 成本失控 | Agent 陷入工具调用死循环 | 设置调用次数/深度上限与预算熔断;监控每轮 token 与工具调用数。 |
| 密钥泄露 | API Key 硬编码进工具函数 | 走环境变量 / 密钥管理服务的 context 注入;工具代码里不出现明文密钥。 |
4. 可观测性与调试
- 接入 LangSmith 追踪官方推荐做法。每一次模型调用、每一次工具调用(含入参、耗时、返回值、异常)都会完整记录,是排查「为什么模型不选这个工具」的唯一有效手段。
- 工具内打点在工具入口记录
name / args / 耗时 / 是否命中缓存 / 错误类型,用runtime.execution_info.thread_id与run_id串联成一条完整链路。 - 给用户可见的进度用
runtime.stream_writer输出中间状态(3.6 节),既改善体验,也方便定位「卡在哪个工具」。 - 建立工具健康看板按工具维度统计:调用次数、成功率、P95 耗时、重试率、平均返回长度。成功率明显低于同类的工具,八成是描述或参数设计有问题。
import time
from functools import wraps
from langchain.tools import tool
METRICS = []
def traced(fn):
"""极简埋点装饰器:记录工具名、耗时、成败。"""
@wraps(fn)
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
try:
out = fn(*args, **kwargs)
METRICS.append({"tool": fn.__name__, "ok": True,
"ms": int((time.perf_counter() - t0) * 1000)})
return out
except Exception as e:
METRICS.append({"tool": fn.__name__, "ok": False,
"err": type(e).__name__,
"ms": int((time.perf_counter() - t0) * 1000)})
raise
return wrapper
@tool
@traced # 注意装饰顺序:@tool 在外,埋点在内
def query_inventory(sku: str) -> str:
"""Query inventory for a SKU."""
return db.query_inventory(sku)
装饰器顺序
多个装饰器叠加时,
@tool要放在最外层。顺序写反会导致@tool拿不到原始函数的类型注解和 docstring,Schema 生成失败或退化。
5.测试策略

import pytest
from langchain.tools import tool
@tool("web_search")
def web_search(query: str) -> str:
"""Search the web for information."""
return f"results:{query}"
def test_schema_contract():
"""Schema 快照测试:防止描述被无意改动。"""
assert web_search.name == "web_search"
assert web_search.description == "Search the web for information."
assert web_search.args["properties"]["query"]["type"] == "string"
assert web_search.args["required"] == ["query"]
def test_invocation():
assert web_search.invoke({"query": "langchain"}) == "results:langchain"
@pytest.mark.parametrize("q,expected", [
("今天北京天气怎么样", "get_weather"),
("帮我算一下 128 * 37", "calculator"),
("订单 SO20260829 到哪了", "query_order"),
])
def test_tool_selection(q, expected):
"""工具选择准确率:直接断言模型产出。"""
msg = model.bind_tools(ALL_TOOLS).invoke([{"role": "user", "content": q}])
assert msg.tool_calls and msg.tool_calls[0]["name"] == expected
6. 反模式清单
| 反模式 | 症状 | 正确做法 |
|---|---|---|
| 万能工具 | 一个 do_everything(action, params),参数全靠一个 dict | 按动作拆成独立工具,参数扁平化 |
| 描述缺失 | docstring 只有一句「处理数据」,模型乱调或不调 | 写清用途、边界、参数格式、返回值 |
| 裸返回大数据 | 一次返回几千字 JSON,上下文爆炸、成本飙升 | 精简字段 + 截断 + 给出总量与「如何缩小范围」的提示 |
| 静默失败 | 出错返回空字符串,模型以为「没有数据」 | 显式返回错误信息并说明如何修正 |
| 身份入参 | get_balance(user_id: str) 让模型填 user_id | 身份从 runtime.context 注入 |
| 无超时无重试 | 偶发网络抖动导致整个 Agent 挂掉 | 设 timeout + 分类重试 + 幂等键 |
| 工具堆砌 | 一个 Agent 挂 40 个工具,准确率断崖下跌 | 动态裁剪到 ≤10,或拆子 Agent |
| 靠 prompt 硬撑 | 用十行提示词求模型「一定要先搜索」 | 用 tool_choice 或 middleware 裁剪 |
| 装饰器顺序错 | @traced 写在 @tool 外面,Schema 生成失败 | @tool 永远放最外层 |
| 生产用 InMemory | 重启即失忆,多副本数据不一致 | 换 PostgresStore / RedisStore;checkpointer 同理 |
上线前自检清单
- 所有工具都有类型注解,且 Schema 与函数签名一致
- 所有 description 都写清了「何时用 / 何时别用 / 参数格式 / 返回值」
- 工具名全部 snake_case,动词开头
- 常驻工具数量 ≤ 10,或已实现动态裁剪
- 工具返回值有长度上限,不会撑爆上下文
- 所有外部调用有 timeout,写操作有幂等键
- 错误信息可被模型理解并自我修正
- 身份信息全部来自 context,工具内部有归属校验
- L2/L3 级副作用已配置人工审批
- 已接入链路追踪,可按 thread_id 还原完整调用链
- 生产环境 store / checkpointer 已替换为持久化实现
- Schema 快照测试与工具选择准确率测试已纳入 CI
350

被折叠的 条评论
为什么被折叠?



