LangChain Tools 实践经验总结

前面四章讲的是「能做什么」,这一章讲「怎么做才不会在生产环境翻车」。全部来自真实项目的经验教训,可以直接当 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_idtenant_idrole 一律走 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. 可观测性与调试

  1. 接入 LangSmith 追踪官方推荐做法。每一次模型调用、每一次工具调用(含入参、耗时、返回值、异常)都会完整记录,是排查「为什么模型不选这个工具」的唯一有效手段。
  2. 工具内打点在工具入口记录 name / args / 耗时 / 是否命中缓存 / 错误类型,用 runtime.execution_info.thread_id 与 run_id 串联成一条完整链路。
  3. 给用户可见的进度用 runtime.stream_writer 输出中间状态(3.6 节),既改善体验,也方便定位「卡在哪个工具」。
  4. 建立工具健康看板按工具维度统计:调用次数、成功率、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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值