临床 Python 进阶路线图
⌕ /
路线图 › 第五阶段 · Agent 核心能力

第 20 章 · 工具调用进阶:契约、校验与边界

本章目标:把工具从"一个函数"升级成一份可校验、可审计、攻不破的契约。 工具设计得好,Agent 就稳;设计得差,再强的模型也救不回来。 配套模块:clinic/agent_core.py(ToolSpec / ToolRegistry)


20.1 一个反直觉的事实

同一个模型、同一个任务,只改工具描述,成功率能从 60% 提到 95%。

原因很简单:LLM 用不用某个工具、怎么传参,几乎完全依赖它读到的那段描述。 它看不到你的实现,也猜不到团队的约定。

所以第 16 章那句话要升级一下:

工具 = 函数 + 给 LLM 看的清单 + 执行入口。 其中"给 LLM 看的清单"是你要花最多时间打磨的部分,不是注解。


20.2 JSON Schema 的四个细节坑

坑 1:description 要写"什么时候用",不只是"是什么"

Python
# ❌ 只说功能 —— 模型不知道该不该用
{
    "name": "run_qc_checks",
    "description": "运行数据质量检查",
}

# ✅ 说清用法与前置条件 —— 模型知道何时用、用之前要做什么
{
    "name": "run_qc_checks",
    "description": (
        "对某个数据集运行标准化质量检查,返回按严重度分级的问题清单。"
        "可检查项:required_vars(必填变量是否齐全)、missing_rate(缺失率)、"
        "key_unique(主键唯一性)、date_pairs(日期先后关系)、codelist(受控术语)。"
        "在调用本工具前,应先调用 describe_dataset 确认变量名。"
        "本工具只读,不修改任何数据。"
    ),
}

最后那句"本工具只读"不是废话 —— 它明确告诉模型这个动作是安全的, 可以放心多调几次。这对探索型任务的成功率影响很大。

坑 2:能枚举就枚举,别给自由文本

Python
# ❌ 自由文本:模型会写出 "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:数据集名必须是枚举(安全 + 准确,一举两得)

Python
{
    "dataset": {
        "type": "string",
        "enum": sorted(ALLOWED_DATASETS),      # dm / adsl / adae / ...
        "description": "数据集名(小写,不含扩展名)",
    }
}

这一条同时解决两个问题:模型不会写错名字,也不会试图访问白名单外的数据。 但注意 —— Schema 的枚举是"提示",不是"安全"。工具实现里 仍然必须再校验一次(见 20.6),因为模型可以绕过 Schema 生成任意参数 (尤其是在多轮对话里被诱导时)。

坑 4:additionalProperties: false

Python
"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 评审)上 用一句话说清这个工具做什么吗?能,就说明粒度对了。

命名:要有区分度

Python
# ❌ 三个名字长得太像,模型经常选错
get_data()   /  get_info()   /  get_stats()

# ✅ 名字自带语义,无需看描述也能猜到区别
describe_dataset()      # 结构:行数、列数、缺失、示例值
summarize_by_group()    # 分组描述统计:n / Mean (SD) / Median
frequency()             # 频数分布:n (%)

另一个实用技巧:用 sas_ 前缀标注"语义与 SAS 对齐"的工具。

Python
name="sas_round_rate"    # 明确告诉模型:这是 SAS 口径的舍入,不是 Python 的

模型看到 sas_ 前缀,就知道这里有个"口径差异"需要注意 —— 这比在描述里写三大段解释有效得多。


20.4 参数校验:在最外层挡住脏数据

LLM 传参的"创意"远超想象。以下是真实会遇到的形态:

Python
# 你以为会收到:
{"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"}                # ★ 恶意的路径穿越

所以校验函数要先归一化,再校验 —— 顺序不能反:

Python
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 之后

路径穿越是工具设计里最经典、也最容易犯的错。看这段"看起来没问题"的代码:

Python
# ❌ 危险:看似校验了目录,实际可以被绕过
def load_dataset(name: str):
    if "../" in name:
        raise ValueError("非法数据集名")
    path = DATA_DIR / f"{name}.csv"
    return pd.read_csv(path)

绕过方式多得是:..%2F、....//、绝对路径、Windows 的 ..\、 甚至是软链接。用字符串匹配防路径穿越,是防不住的。

正确做法是把"允许的名字"变成枚举白名单,并且只做映射,不做拼接:

Python
# ✅ 白名单 + 只映射不拼接
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 结构化错误:让模型能自己修

这是"工具做得好"和"工具做得凑合"分水岭最明显的地方。

Python
# ❌ 第 16 章的做法(教学版可以,工程版不行)
except Exception as e:
    return f"错误:{e}"

# 实际会返回这种东西给模型:
# "错误:'NoneType' object has no attribute 'upper'"
# 模型看到这条,唯一能做的就是瞎猜着改参数 —— 或者干脆放弃
Python
# ✅ 结构化错误:类型 + 说明 + 怎么改 + 候选值
@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 会炸

Python
# ❌ 工具直接返回 DataFrame
return df.head(100)

# 后面某处 state.to_json() 时:
# TypeError: Object of type DataFrame is not JSON serializable

更糟的是"能序列化但没意义":

Python
# ❌ 这段看起来成功了 —— 但它把 DataFrame 转成了一坨
json.dumps(df, default=str)     # → "   USUBJID  TRT01P  AGE\n0  01-701-1015 ..."

一个几千行的 CSV 字符串塞进上下文,既烧钱又没用。

Python
# ✅ 工具自己负责把结果转成"小而结构化"的形式
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。

Python
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 发消息 否 同事收到两封邮件
Python
@dataclass
class ToolSpec:
    ...
    side_effect: str = "read"      # read / write / irreversible
    idempotent: bool = True        # 重复执行是否安全

幂等键(idempotency key)是处理非幂等操作的标准手段:

Python
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 与业务之间的唯一接口,所以它比普通函数更需要测试。 而且测试成本很低 —— 工具都是纯函数。

Python
# 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 本章小结

  1. 工具描述是提示工程里性价比最高的一块 —— 改描述就能提成功率
  2. 能枚举的都枚举 —— Schema 本身就是约束
  3. 校验要先归一化再判断 —— 但要区分"格式差异"与"语义错误"
  4. 白名单是映射不是拼接 —— 输入永不参与路径构造
  5. 结构化错误让 Agent 能自愈 —— 错误里要带"怎么改"和"能填什么"
  6. 结果必须小、结构化、可序列化 —— 截断时要明说
  7. 契约测试只要四条断言 —— 收益却极高

下一章:上下文窗口是硬约束 —— 怎么让 Agent 记住该记的、忘掉该忘的。


20.13 动手练习

  1. 给 clinic/agent_tools.py 的每个参数补上 description, 然后对照 20.2 的第 1 条,把"本工具只读"这类信息补进去。
  2. 写一个 _norm_int(raw),接受 "5" / 5 / 5.0 / "5 ",拒绝 "abc"。
  3. 故意在 describe_dataset 里加一行 if True: raise RuntimeError("boom"), 观察返回给模型的内容。然后改成结构化错误重试一次,对比模型后续行为。
  4. 给 frequency() 加一个 top_n 参数(默认 20), 让它在结果里明确标注"仅显示前 N 项,共 M 项"。
  5. 思考题:如果工具的 enum 里包含 200 个受控术语(CT)的值, 该继续用 enum 吗?还是换成别的设计?(提示:想想 token 成本 和"模型根本读不完"这两件事)