本章目标:把工具从"一个函数"升级成一份可校验、可审计、攻不破的契约。 工具设计得好,Agent 就稳;设计得差,再强的模型也救不回来。 配套模块:
clinic/agent_core.py(ToolSpec/ToolRegistry)
20.1 一个反直觉的事实
同一个模型、同一个任务,只改工具描述,成功率能从 60% 提到 95%。
原因很简单:LLM 用不用某个工具、怎么传参,几乎完全依赖它读到的那段描述。 它看不到你的实现,也猜不到团队的约定。
所以第 16 章那句话要升级一下:
工具 = 函数 + 给 LLM 看的清单 + 执行入口。 其中"给 LLM 看的清单"是你要花最多时间打磨的部分,不是注解。
20.2 JSON Schema 的四个细节坑
坑 1:description 要写"什么时候用",不只是"是什么"
# ❌ 只说功能 —— 模型不知道该不该用
{
"name": "run_qc_checks",
"description": "运行数据质量检查",
}
# ✅ 说清用法与前置条件 —— 模型知道何时用、用之前要做什么
{
"name": "run_qc_checks",
"description": (
"对某个数据集运行标准化质量检查,返回按严重度分级的问题清单。"
"可检查项:required_vars(必填变量是否齐全)、missing_rate(缺失率)、"
"key_unique(主键唯一性)、date_pairs(日期先后关系)、codelist(受控术语)。"
"在调用本工具前,应先调用 describe_dataset 确认变量名。"
"本工具只读,不修改任何数据。"
),
}
最后那句"本工具只读"不是废话 —— 它明确告诉模型这个动作是安全的, 可以放心多调几次。这对探索型任务的成功率影响很大。
坑 2:能枚举就枚举,别给自由文本
# ❌ 自由文本:模型会写出 "requiredVars"、"必填变量"、"required_vars " 各种变体
{"checks": {"type": "array", "items": {"type": "string"}}}
# ✅ 枚举:模型只能在有限集合里选
{
"checks": {
"type": "array",
"items": {
"type": "string",
"enum": ["required_vars", "missing_rate", "key_unique",
"date_pairs", "codelist"],
},
"description": "要执行的检查项;不传则执行全部",
}
}
🔥 枚举是"用 Schema 做提示工程"。每多一个
enum, 你就少一个需要靠提示词纠正的失败模式。
坑 3:数据集名必须是枚举(安全 + 准确,一举两得)
{
"dataset": {
"type": "string",
"enum": sorted(ALLOWED_DATASETS), # dm / adsl / adae / ...
"description": "数据集名(小写,不含扩展名)",
}
}
这一条同时解决两个问题:模型不会写错名字,也不会试图访问白名单外的数据。 但注意 —— Schema 的枚举是"提示",不是"安全"。工具实现里 仍然必须再校验一次(见 20.6),因为模型可以绕过 Schema 生成任意参数 (尤其是在多轮对话里被诱导时)。
坑 4:additionalProperties: false
"parameters": {
"type": "object",
"properties": {...},
"required": ["dataset"],
"additionalProperties": False, # ← 拦住模型自创的参数
}
不加这一条,模型可能传 {"dataset": "adsl", "explain": True} 这种它自己发明的参数。
严格模式下,多传的参数会被拒绝并给出结构化错误,模型下一轮就学乖了。
20.3 粒度与命名
粒度:一个工具 = 一个业务动作
| 太细 | 合适 | 太粗 |
|---|---|---|
read_csv、get_columns、count_rows |
describe_dataset |
analyze_everything |
| 模型要在 30 个工具里选 | 5–15 个,每个对应一件"业务上说得出口"的事 | 模型不知道这个黑盒里有什么 |
"业务上说得出口"是很好的检验标准:你能在方法学会议(或 SAP 评审)上 用一句话说清这个工具做什么吗?能,就说明粒度对了。
命名:要有区分度
# ❌ 三个名字长得太像,模型经常选错
get_data() / get_info() / get_stats()
# ✅ 名字自带语义,无需看描述也能猜到区别
describe_dataset() # 结构:行数、列数、缺失、示例值
summarize_by_group() # 分组描述统计:n / Mean (SD) / Median
frequency() # 频数分布:n (%)
另一个实用技巧:用 sas_ 前缀标注"语义与 SAS 对齐"的工具。
name="sas_round_rate" # 明确告诉模型:这是 SAS 口径的舍入,不是 Python 的
模型看到 sas_ 前缀,就知道这里有个"口径差异"需要注意 ——
这比在描述里写三大段解释有效得多。
20.4 参数校验:在最外层挡住脏数据
LLM 传参的"创意"远超想象。以下是真实会遇到的形态:
# 你以为会收到:
{"dataset": "adsl", "subjects": ["01-701-1015", "01-701-1023"]}
# 实际可能收到:
{"dataset": "ADSL"} # 大小写
{"dataset": "adsl.csv"} # 带扩展名
{"dataset": "adsl "} # 带空格
{"subjects": "01-701-1015"} # 单值当列表
{"subjects": "01-701-1015,01-701-1023"} # 逗号串
{"subjects": ["01-701-1015", "01-701-1015"]} # 重复
{"subjects": "全部"} # 自然语言
{"dataset": "../../../../etc/passwd"} # ★ 恶意的路径穿越
所以校验函数要先归一化,再校验 —— 顺序不能反:
def _norm_dataset(raw: Any) -> str:
"""把 LLM 给的各种形态归一化成一个干净的数据集名。"""
s = str(raw).strip().lower()
if s.endswith(".csv"):
s = s[:-4]
if s.endswith(".xpt"):
s = s[:-4]
return s
def _norm_subjects(raw: Any) -> list[str]:
"""单值 / 逗号串 / 列表 都接受,去重保序。"""
if raw is None:
return []
if isinstance(raw, str):
parts = [p.strip() for p in raw.replace(";", ",").split(",")]
elif isinstance(raw, (list, tuple, set)):
parts = [str(p).strip() for p in raw]
else:
parts = [str(raw).strip()]
out, seen = [], set()
for p in parts:
if p and p not in seen:
seen.add(p)
out.append(p)
return out
⚠️ 归一化不是"纵容"。归一化的是无害的格式差异(大小写、空格、单值包装); 不该归一化的是语义错误("全部"应该报错并提示枚举值,不能猜)。
20.5 安全边界:白名单必须在 normpath 之后
路径穿越是工具设计里最经典、也最容易犯的错。看这段"看起来没问题"的代码:
# ❌ 危险:看似校验了目录,实际可以被绕过
def load_dataset(name: str):
if "../" in name:
raise ValueError("非法数据集名")
path = DATA_DIR / f"{name}.csv"
return pd.read_csv(path)
绕过方式多得是:..%2F、....//、绝对路径、Windows 的 ..\、
甚至是软链接。用字符串匹配防路径穿越,是防不住的。
正确做法是把"允许的名字"变成枚举白名单,并且只做映射,不做拼接:
# ✅ 白名单 + 只映射不拼接
ALLOWED = {
"dm": "dm.csv",
"adsl": "adsl.csv",
"adae": "adae.csv",
"ae": "ae.csv",
"vs": "vs_bp.csv",
"adlbc": "adlbc_shift.csv",
}
def _load(name: str) -> pd.DataFrame:
key = _norm_dataset(name)
if key not in ALLOWED: # 不在白名单 → 直接拒绝
raise PermissionError(
f"数据集 {name!r} 不在允许列表中。可用:{sorted(ALLOWED)}"
)
path = (DATA_DIR / ALLOWED[key]).resolve() # 路径完全由白名单决定
# 双保险:确认解析后的路径确实还在数据目录内
if not str(path).startswith(str(DATA_DIR.resolve())):
raise PermissionError("路径越界")
return pd.read_csv(path, low_memory=False)
关键点:用户输入从来不参与路径拼接。它在白名单里查表,只用来"选",
不用来"拼"。这样任何 ../、绝对路径、Unicode 混淆都无效。
🔥 第 18 章说"边界要写在
if里" —— 这里就是那个if的具体写法。 注意name!r和把它原样回显给模型:拒绝时要说清"允许什么", 否则模型会在下一轮继续猜。
20.6 结构化错误:让模型能自己修
这是"工具做得好"和"工具做得凑合"分水岭最明显的地方。
# ❌ 第 16 章的做法(教学版可以,工程版不行)
except Exception as e:
return f"错误:{e}"
# 实际会返回这种东西给模型:
# "错误:'NoneType' object has no attribute 'upper'"
# 模型看到这条,唯一能做的就是瞎猜着改参数 —— 或者干脆放弃
# ✅ 结构化错误:类型 + 说明 + 怎么改 + 候选值
@dataclass
class ToolResult:
ok: bool
data: Any = None
error: str | None = None
error_type: str | None = None # validation/not_found/permission/transient/internal
hint: str | None = None # ★ 给模型的修复建议
options: list[str] | None = None # ★ 合法取值
def to_llm_text(self) -> str:
if self.ok:
return _render(self.data)
parts = [f"[{self.error_type}] {self.error}"]
if self.options:
parts.append(f"可选值:{self.options}")
if self.hint:
parts.append(f"提示:{self.hint}")
return "\n".join(parts)
对比一下同一件事返回给模型的样子:
❌ 无结构:
错误:数据集 adls 不存在
✅ 有结构:
[not_found] 数据集 'adls' 不存在
可选值:['adae', 'adlbc', 'adsl', 'ae', 'dm', 'vs']
提示:可能是拼写错误(adls → adsl)。请用枚举中的值重试。
模型看到第二种,基本下一轮就能改对。 这不是锦上添花 —— 它是"Agent 能不能自己从错误里恢复"的决定性因素。
错误类型与处理策略的对应
error_type |
含义 | 谁来解决 |
|---|---|---|
validation |
参数格式/取值不对 | 模型自己改(回给模型) |
not_found |
目标不存在 | 模型换目标 |
permission |
越权 | 终止,告警给人 |
transient |
网络/超时 | 框架重试 |
internal |
代码 bug | 终止,记录堆栈 |
⚠️ 注意
permission与internal都不该回给模型 —— 前者可能被诱导绕过,后者模型帮不上忙只会瞎试。直接终止更安全。
20.7 结果侧:可序列化 + 长度受控
问题 1:返回 DataFrame 会炸
# ❌ 工具直接返回 DataFrame
return df.head(100)
# 后面某处 state.to_json() 时:
# TypeError: Object of type DataFrame is not JSON serializable
更糟的是"能序列化但没意义":
# ❌ 这段看起来成功了 —— 但它把 DataFrame 转成了一坨
json.dumps(df, default=str) # → " USUBJID TRT01P AGE\n0 01-701-1015 ..."
一个几千行的 CSV 字符串塞进上下文,既烧钱又没用。
# ✅ 工具自己负责把结果转成"小而结构化"的形式
def summarize_by_group(dataset: str, group: str, var: str) -> ToolResult:
...
table = []
for g, sub in df.groupby(group, observed=True):
s = pd.to_numeric(sub[var], errors="coerce")
table.append({
"组": str(g),
"n": int(s.notna().sum()),
"Mean (SD)": f"{s.mean():.2f} ({s.std(ddof=1):.2f})",
"Median": float(s.median()),
"Min, Max": f"{s.min():.1f}, {s.max():.1f}",
})
return ToolResult.ok({
"数据集": dataset, "分组": group, "变量": var,
"结果": table, # ← 小而结构化
"缺失": int(s.isna().sum()),
})
规则:进入 ToolResult.data 的必须是基本类型
(str / int / float / bool / None / list / dict)。
问题 2:结果太长会撑爆上下文
一个 2000 行的 AE 列表,直接塞进上下文,一次就烧掉几万 token。
def _cap(rows: list, limit: int = 50) -> dict:
"""统一的结果长度控制:截断 + 摘要 + 明确告知被截断。"""
if len(rows) <= limit:
return {"行": rows, "总数": len(rows)}
return {
"行": rows[:limit],
"总数": len(rows),
"已截断": True,
"说明": f"仅返回前 {limit} 行,共 {len(rows)} 行;"
f"如需完整结果,请按条件缩小范围或使用汇总工具。",
}
⚠️ "已截断"必须明确写出来。否则模型会把"前 50 行"当成全部数据, 得出"只有 50 个受试者"这种结论 —— 这是数据层面的幻觉, 比语言层面的幻觉更危险,因为它看起来有工具结果支撑。
20.8 幂等性与副作用
| 工具 | 副作用 | 幂等? | 重复调用的后果 |
|---|---|---|---|
describe_dataset |
无 | — | 无 |
run_qc_checks |
无 | — | 无 |
write_report |
写文件 | 是(同名覆盖) | 无 |
append_audit |
追加写 | 否 | 审计日志出现重复行 |
send_summary |
发消息 | 否 | 同事收到两封邮件 |
@dataclass
class ToolSpec:
...
side_effect: str = "read" # read / write / irreversible
idempotent: bool = True # 重复执行是否安全
幂等键(idempotency key)是处理非幂等操作的标准手段:
def append_audit(event: dict, idem_key: str | None = None) -> ToolResult:
"""追加审计事件。带 idem_key 时,同一 key 只写入一次。"""
if idem_key and _seen(idem_key):
return ToolResult.ok({"重复": True, "说明": "该事件已记录,跳过"})
...
在 Agent 场景里,重试是常态(网络抖动、模型重发), 所以任何非幂等的工具都应该支持幂等键。
20.9 工具契约测试
工具是 Agent 与业务之间的唯一接口,所以它比普通函数更需要测试。 而且测试成本很低 —— 工具都是纯函数。
# tests/test_agent_tools.py
import pytest
from clinic.agent_tools import build_registry, ALLOWED
@pytest.fixture(scope="module")
def reg():
return build_registry()
# ---- 1) Schema 自身要合法 ----
def test_every_tool_has_valid_schema(reg):
for spec in reg.specs():
assert spec.name and spec.description, f"{spec.name} 缺名字或描述"
assert spec.parameters["type"] == "object"
assert spec.parameters.get("additionalProperties") is False
for p in spec.parameters.get("properties", {}).values():
assert "description" in p, "每个参数都要有 description"
# ---- 2) 参数归一化的边界 ----
@pytest.mark.parametrize("raw,expect", [
("ADSL", "adsl"), ("adsl.csv", "adsl"), (" adsl ", "adsl"),
])
def test_dataset_normalization(reg, raw, expect):
assert reg.execute("describe_dataset", {"dataset": raw}).ok
@pytest.mark.parametrize("raw", ["../etc/passwd", "adls", "", "全部", None])
def test_rejects_bad_dataset(reg, raw):
res = reg.execute("describe_dataset", {"dataset": raw})
assert not res.ok
assert res.error_type in {"not_found", "validation", "permission"}
# ---- 3) 结果必须可 JSON 序列化 ----
def test_results_are_serializable(reg):
import json
for name in ("describe_dataset", "run_qc_checks", "frequency"):
res = reg.execute(name, {"dataset": "adsl"})
json.dumps(res.data, ensure_ascii=False) # 不抛异常即通过
# ---- 4) 错误必须结构化 ----
def test_errors_are_actionable(reg):
res = reg.execute("describe_dataset", {"dataset": "nope"})
assert res.error_type and res.options # 有类型、有候选值
🔥 第 3 条测试(
test_results_are_serializable)值得单独强调: 它能拦住 90% 的"上线后才发现"的 Agent 故障。 因为序列化失败的报错位置离出错位置很远(在存状态的时候,不在工具里), 排查起来极其费劲。
20.10 MCP:工具标准化的意义
前面所有内容都假设"工具是你自己写的"。但如果工具要跨项目复用呢?
比如你们组做了一套"临床数据检查工具",另一个组的 Agent 也想用。 传统做法是复制代码,然后各自分叉、各自修 bug。
MCP(Model Context Protocol) 是 Anthropic 在 2024 年提出的开放协议, 把这个过程标准化了:工具作为一个独立服务暴露, 任何支持 MCP 的客户端都能接入。
不使用 MCP 使用 MCP
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Agent A │ │ Agent A │ │ Agent B │
│ 工具代码 │ └────┬─────┘ └────┬─────┘
└──────────┘ │ │
┌──────────┐ └──────┬───────┘
│ Agent B │ ▼
│ 工具代码 │ ← 复制一份 ┌───────────┐
└──────────┘ │ MCP 服务 │
│(唯一一份)│
改一处要改两处 └───────────┘
对临床统计团队的实际价值:
| 场景 | 不用 MCP | 用 MCP |
|---|---|---|
| 共享 QC 工具 | 复制代码,各自维护 | 一个服务,所有人调用 |
| 接入内部系统(EDC/统计平台) | 每个 Agent 各写一遍连接逻辑 | 写一个 MCP 服务 |
| 权限与审计 | 分散在各处,无法统一 | 集中在一个服务里 |
🔥 最后一行才是重点:MCP 让"安全边界"和"审计日志"有了唯一的落点。 工具散落在 5 个 Agent 里,你没法回答"谁在什么时候访问过受试者数据"。
想动手的话,pip install mcp,把 clinic/agent_tools.py 里的工具
一个个注册出去大约 30 行。本仓库暂不包含 MCP 服务实现(避免引入额外依赖),
但第 23 章会讲服务化封装,思路是相通的。
20.11 常见误区
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 描述只写功能 | 模型不知道何时用 | 写清用途、前置条件、是否只读 |
| 参数用自由文本 | 各种拼写变体 | 能枚举就枚举 |
| 靠字符串匹配防路径穿越 | 必被绕过 | 白名单映射,输入不参与拼接 |
| 返回 DataFrame | 序列化炸 / 上下文爆炸 | 工具内转成基本类型 |
| 返回超长结果不截断 | 一次烧几万 token | _cap() + 明确标注已截断 |
| 错误直接抛 traceback | 模型无法自救 | 结构化错误 + 候选值 + 提示 |
| 不写契约测试 | 上线后才发现工具返回不能序列化 | 四条基础断言(见 20.9) |
| 工具越多越好 | 选择错误率飙升 | 5–15 个,一个业务动作一个 |
20.12 本章小结
- 工具描述是提示工程里性价比最高的一块 —— 改描述就能提成功率
- 能枚举的都枚举 —— Schema 本身就是约束
- 校验要先归一化再判断 —— 但要区分"格式差异"与"语义错误"
- 白名单是映射不是拼接 —— 输入永不参与路径构造
- 结构化错误让 Agent 能自愈 —— 错误里要带"怎么改"和"能填什么"
- 结果必须小、结构化、可序列化 —— 截断时要明说
- 契约测试只要四条断言 —— 收益却极高
下一章:上下文窗口是硬约束 —— 怎么让 Agent 记住该记的、忘掉该忘的。
20.13 动手练习
- 给
clinic/agent_tools.py的每个参数补上description, 然后对照 20.2 的第 1 条,把"本工具只读"这类信息补进去。 - 写一个
_norm_int(raw),接受"5"/5/5.0/"5 ",拒绝"abc"。 - 故意在
describe_dataset里加一行if True: raise RuntimeError("boom"), 观察返回给模型的内容。然后改成结构化错误重试一次,对比模型后续行为。 - 给
frequency()加一个top_n参数(默认 20), 让它在结果里明确标注"仅显示前 N 项,共 M 项"。 - 思考题:如果工具的
enum里包含 200 个受控术语(CT)的值, 该继续用enum吗?还是换成别的设计?(提示:想想 token 成本 和"模型根本读不完"这两件事)