Day 30 / 共 68 天 · 阶段 5 工具与 MCP

写安全的工具:Agent 靠不靠谱,全看工具

昨天(Day 29)模型学会了"点菜"——说出要调哪个函数。但"菜"(工具)本身写得糙,再聪明的模型也会翻车。今天讲把工具写得安全可靠的四条硬功夫:①工具描述怎么写模型才点得准;②怎么校验模型传来的参数;③出错时为什么要返回结构化错误而不是抛异常;④为什么工具要尽量幂等、危险动作要先审批。这是可靠 Agent 的地基;明天(Day 31)进入 MCP——工具的"通用插座"。

📍 你在阶段 5(工具与 MCP D29-32)的位置
D29 Function Calling D30 写安全工具 D31 MCP 入门 D32 多步工具链
💡 用一个类比兜住今天(今天全程沿用「给一位新来的实习生配工具箱」的世界观) 写工具 = 给一位聪明但莽撞的实习生(模型)配工具箱工具描述 = 每件工具贴的使用说明标签(写不清,实习生就乱用);参数校验 = 工具自带的防呆设计(插反了就卡住不让你硬来);结构化错误 = 工具坏了会亮红灯提示"哪里坏了、怎么办",而不是"啪"地爆炸伤到人;幂等 = 同一个按钮多按几下也不会重复扣款、重复发货;危险动作审批 = 删库、转账这类工具上把锁,动它先喊主管确认。核心心态:永远假设这位实习生会犯错、会乱传参数、会重复点——你的工具要替他兜住。
L01

为什么工具是最脆弱的一环

🤔 痛点Day29 的例子跑得挺顺。可真实里:模型把城市名填成"北京市"而不是"北京"、天气 API 突然超时、或者它连着调 100 次把接口打爆……一个工具挂了,整个 Agent 就卡死或崩溃。
💡 本质模型是"聪明但不受控"的调用方——它会乱填参数、误判该不该调、反复重试。工具是 Agent 唯一真正"动手碰外部世界"的地方(改数据、花钱、发消息),所以工具是最容易出事、后果最严重的一环。写工具的核心不是"让它能用",而是"让它在被乱用时也不出大乱子"。
一句话总结今天的心法:把模型当成一个会犯错的用户来防御——这跟你 Day09 学 Web 后端"永远不信任前端传来的数据"是同一个道理,只不过这次"不可信的调用方"是 AI。
L02

工具描述:这是写给模型看的说明书

🤔 痛点Day29 说过 description 很重要。可到底怎么写?写太简单模型点错菜,写太啰嗦又浪费 token。
💡 本质工具描述是模型判断"该不该用、怎么用"的唯一依据——它看不到你的函数代码,只能读这段说明。三条原则:①说清什么时候该用(触发场景)、②每个参数写明含义和格式举例、③说清返回什么。像给实习生贴的工具标签:含糊 → 乱用;清楚 → 用对。
// ❌ 反面:模型根本不知道啥时候用、city 该填啥格式
{ "name": "weather", "description": "天气",
  "parameters": {"properties": {"city": {"type": "string"}}} }

// ✅ 正面:触发场景清楚、参数有格式示例、边界说明
{ "name": "get_weather",
  "description": "查询指定城市当前天气。当用户询问天气、气温、冷热、是否下雨、要不要带伞时调用;仅支持中国城市。",
  "parameters": {"type": "object",
    "properties": {
      "city": {"type": "string",
               "description": "城市名,不带'市'字,如 北京、上海、杭州"}},
    "required": ["city"]} }
📝 举个例子:一句话说明救回一次误调 模型面对"武汉热不热" —— 描述含糊的 weather 它可能不确定要不要调;而写了"当用户询问天气、气温、冷热时调用"的版本,它一眼锁定该用,还知道 city 填"武汉"不填"武汉市"。好描述让模型少犯错,比事后补一堆校验划算得多。
L03

校验参数:别信模型填的

🤔 痛点描述写得再好,模型还是可能把 city 填成空、填成拼音、或漏掉必填项。直接拿去用,轻则报错崩溃,重则查了个错东西还一本正经回答。
💡 本质工具函数第一件事就是校验入参:必填的有没有、类型对不对、值在不在合理范围。不合格就立刻返回一个清楚的错误(下一讲讲怎么返回)。用 Day05 学的 pydantic 或简单 if 都行。这就是工具的"防呆设计":插反了就卡住,不让模型硬来。
SUPPORTED = {"北京", "上海", "杭州", "武汉"}   # 白名单:只认这些城市

def get_weather(city: str = ""):
    # 1) 校验:必填、非空、在支持范围内
    if not city or not isinstance(city, str):
        return {"ok": False, "error": "缺少参数 city(城市名)"}
    city = city.strip().replace("市", "")      # 容错:去空格、去"市"字
    if city not in SUPPORTED:
        return {"ok": False,
                "error": f"暂不支持城市「{city}」,仅支持:{','.join(SUPPORTED)}"}
    # 2) 校验通过,才真正干活
    return {"ok": True, "city": city, "temp": 3, "desc": "晴"}
👶 为啥要"去掉市字"这种容错因为模型经常填"北京市""武汉市"。与其严格报错让它重试(费一轮 token),不如在工具里做点无害的宽容归一化(去空格、去后缀)。原则:对输入宽容(能救则救)、对输出严格(格式统一)——这条叫"鲁棒性法则",工程里到处适用。
L04

出错要"返回结构化错误",不要抛异常

🤔 痛点工具内部出错了(API 超时、城市不支持),按 Day05 学的直接 raise 抛异常?那异常会一路炸穿,让整个 Agent 循环崩掉——一个小工具挂了,全盘皆输。
💡 本质Agent 工具的黄金准则:把错误"接住",打包成一个结构化结果返回给模型,而不是抛异常。为什么?因为这个错误要喂回给模型看(回忆 Day29 的第④步),模型读到"城市不支持"才能改正重试或换个说法。像工具坏了亮红灯提示"哪里坏了",而不是当场爆炸。
抛异常 vs 返回结构化错误 ❌ raise 抛异常 异常一路炸穿 Agent 循环崩溃 模型看不到、没法补救 ✅ 返回 {ok:false, error} 错误当结果喂回模型 模型读懂→改正/换招 Agent 继续跑,不崩
图注:工具的错误是"给模型的信息",不是"程序的崩溃"。接住它、结构化、喂回去——Agent 才有自愈能力。
import requests

def get_weather(city):
    try:
        # 假设调真实天气 API(可能超时、可能返回 500)
        r = requests.get("https://api.example.com/w", params={"city": city}, timeout=5)
        r.raise_for_status()
        return {"ok": True, "data": r.json()}
    except requests.Timeout:
        return {"ok": False, "error": "天气服务超时,请稍后重试"}   # 不抛,返回
    except Exception as e:
        # 兜底:任何意外都变成结构化错误,绝不让异常炸出去
        return {"ok": False, "error": f"查询失败:{type(e).__name__}"}

👶 小白:那 error 信息里能不能把完整报错堆栈都塞给模型,方便它排查?

👨‍🏫 老师:不建议。①堆栈又长又占 token;②里面可能含敏感信息(内部路径、密钥、数据库地址),喂给模型甚至可能被套话泄露。给模型的 error 要简短、可行动("城市不支持,请从 X 中选");完整堆栈写进你自己的日志(Day55 讲可观测会细说),给人排查用。给模型看的和给工程师看的,是两份不同的信息。

L05

幂等 & 危险动作要审批

🤔 痛点模型有时会因为没看到结果而重复调同一个工具。如果这工具是"转账""下单""删文件",重复执行 = 重复扣钱、重复发货、误删——灾难。
💡 本质两道保险:
幂等(idempotent):同一个操作调 1 次和调 5 次结果一样。做法如带一个唯一 request_id,重复请求直接返回上次结果、不再执行。像电梯按钮:狂按也只上一次。
危险动作先审批:删数据、花钱、对外发消息这类"不可逆/有代价"的工具,不让模型自动执行,而是暂停、把要做的事亮给人确认(human-in-the-loop),点头才继续。
工具类型风险该加的保险
只读(查天气、查库存)参数校验 + 结构化错误即可,可放心自动调
写但可逆(改草稿、加标签)上面 + 幂等设计(带唯一 id 去重)
写且不可逆(转账、删除、发邮件)上面 + 危险动作审批(human-in-the-loop)+ 预算/次数上限
📝 举个例子:一个 request_id 挡住重复下单 下单工具接收模型传的 order_id="A1001"。工具先查"A1001 处理过没?"——没有才真下单并记下;若模型因超时重发同一个 A1001,工具直接返回上次的结果、不会二次下单这个"先查再做"的去重,就是幂等最常见的落地方式,面试常考。
L06

工业级示范 & 深潜入口

🤔 痛点这些原则(好描述、校验、结构化错误、幂等、审批)真实的成熟产品是怎么落地的?有没有教科书级的例子可以照抄?
💡 本质有。Claude Code(Anthropic 官方的编程 Agent)本身就是一套"工业级工具设计"的活教材:它有一堆工具(读文件、改文件、执行命令、搜索…),每个都精心写了描述、做了严格的参数校验和权限控制、危险操作(如执行命令、写文件)都走审批与沙箱。想看真实产品里"安全工具"到底怎么设计、权限系统怎么搭,这套源码精讲值得一读:
🔗 想深入?去看《Claude Code 源码精讲》→
👶 现在就要全看懂吗不用。你现在处于"打地基"阶段,先把今天这五条原则记牢、能在自己的小工具里用上就够。深潜是加餐——等你 Day31(MCP)、Day33(Agent 主循环)学完再回看,会更有共鸣。学完记得回来继续 Day 31,主线优先。
L07

今日小结 + 动手 10 分钟

🧠 今天你应该能回答

  • 为什么说工具是 Agent 最脆弱、最需要防御的一环?核心心态是什么?
  • 好的工具描述要写清哪三点?为什么它比事后补校验更划算?
  • 为什么工具第一步要校验模型传的参数?"对输入宽容、对输出严格"什么意思?
  • 工具出错时为什么要返回结构化错误而不是抛异常?给模型的 error 和给工程师的日志有何不同?
  • 幂等是什么?哪类工具必须做?危险动作为什么要走审批?

✋ 动手 10 分钟:把"裸工具"改造成"安全工具"

零依赖零成本。下面是一个"裸"的转账工具,请你按今天五条原则给它加上防护(校验参数 + 结构化错误 + 幂等去重):

# ❌ 改造前:没校验、会崩、会重复转账
# def transfer(to, amount): bank[to] += amount

_done = set()               # 记录处理过的请求 id(幂等用)
bank = {"小明": 0}

def transfer(req_id="", to="", amount=0):
    # ① 参数校验
    if not req_id or not to:
        return {"ok": False, "error": "缺少 req_id 或 to"}
    if not isinstance(amount, (int, float)) or amount <= 0:
        return {"ok": False, "error": "amount 必须是正数"}
    if to not in bank:
        return {"ok": False, "error": f"收款人「{to}」不存在"}
    # ② 幂等:同一个 req_id 只执行一次
    if req_id in _done:
        return {"ok": True, "note": "该请求已处理过(幂等,未重复转账)"}
    # ③ 真正执行
    bank[to] += amount
    _done.add(req_id)
    return {"ok": True, "to": to, "balance": bank[to]}

print(transfer("R1", "小明", 100))   # {'ok': True, 'to': '小明', 'balance': 100}
print(transfer("R1", "小明", 100))   # 重复 R1 → 不再加钱(幂等生效)
print(transfer("R2", "小红", 50))    # 收款人不存在 → 结构化错误,不崩

挑战:再给它加一句"金额 > 10000 时返回 {'ok': False, 'need_approval': True}",模拟"危险动作要审批"。

明日预告 · Day 31:你会写单个安全工具了。可每接一个新工具都要重写一遍对接代码,太累。明天学 MCP(Model Context Protocol)——工具的"通用插座/USB 标准":把工具做成标准化的 server,任何支持 MCP 的 Agent(如 Claude Code)插上就能用,不必为每个模型重写。这是 2024 年以来最重要的 Agent 生态标准之一,面试越来越爱问。
← Day 29 · Function Calling 原理 Day 31 · MCP 入门 →