2.5 为什么模型本身不能「动手」
一句话结论:模型能输出一切"能被文本表示"的东西——包括一个工具调用请求——但它永远不执行任何东西。"动手"这个能力,从头到尾都在你的代码里。 认清这一点,Agent 的安全、可控、可审计才有了落脚点。
读这篇之前
需要先读 1.5(模型只输出 tool_calls)和 2.1(模型在预测 Token)。这一篇是第 2 章的收束,把"模型边界"讲到底。
一、把话说到底:模型的输出是什么
模型的输出,本质上就是一串 Token。这串 Token 可以被解释成各种东西:
| 你让它输出 | 它实际给了你 | 谁来解释 |
|---|---|---|
| 一段话 | 文字 | 人眼 |
| 一段 JSON | 符合 JSON 形状的文字 | 你的解析器 |
| 一个工具调用 | tool_calls 结构化字段 |
你的框架代码 |
| 一段代码 | 代码文本 | 你的执行环境 |
| 一条 SQL | SQL 文本 | 你的数据库客户端 |
注意最后三行:模型交付的是"文本",执行是后面那一列的事。
所以当有人说"模型把生产库删了"时,准确的说法是:模型输出了那条语句,而某个没有做校验的程序把它交给了数据库执行。
这个区分不是文字游戏,它决定了责任归属和防护位置:
- 如果风险在模型 → 你要换模型、加提示词
- 如果风险在执行层 → 你要在执行层加校验、加白名单、加人工确认
真正的风险在执行层。 这就是为什么第 14 章整章都在讲权限与人在环。
二、为什么"让模型直接动手"在设计上不可行
有人会问:既然模型能生成代码,为什么不让它直接执行?
原因有三层,从根本到表面:
第一层:它没有执行环境
模型是一个函数:给 Token,返回 Token。它没有文件系统、没有网络、没有进程。
要让它"动手",必须有外部程序代为执行——这就是工具。
第二层:它的输出不具备"可信"属性
即使你让它生成一段脚本并执行,这段脚本的正确性没有任何保证:
- 它可能基于错误的前提(2.3 幻觉)
- 它可能用了不存在的参数(具体细节最易编)
- 它可能在小概率下生成了完全不同的内容(2.4 采样)
让一个概率生成器直接驱动不可逆操作,在工程上不可接受。
第三层:它无法感知执行的后果
模型不能"看到"执行的结果,除非你把结果放进上下文再调用一次(1.3)。
所以即使让它执行了,它也不知道执行对不对——中间必须有一个能判断、能回滚、能拦截的层。
三、工具层应该承担什么
既然执行在我们手里,那么工具层就不是"简单的转发",而是一道必须做判断的关卡。
一个负责任的工具层至少要做五件事:
| 职责 | 具体做法 | 不做会怎样 |
|---|---|---|
| 参数校验 | 类型、范围、必填项、格式 | 模型传错参数导致误操作 |
| 权限检查 | 这个任务允许调这个工具吗 | 越权访问 |
| 副作用分级 | 读操作直接放行,写操作走审批 | 不可逆损害 |
| 结果归一化 | 排序固定、剔除噪声字段 | 上下文不稳定(2.4) |
| 失败封装 | 把异常转成模型能理解的错误信息 | 模型无法自我纠正 |
最后一条常被忽视,但它直接决定 Agent 的"纠错能力":
❌ 把异常原样抛出:Error: ECONNREFUSED 127.0.0.1:5432
✅ 转成模型能用的信息:数据库连接失败(服务未启动或端口不通),建议先确认数据库是否运行
错误信息的质量,决定了模型能不能自我修复。 这本质上也是上下文工程(2.2)的一部分。
四、一个完整的例子:把"看天气"拆到最底层
把整个过程拆到你能看清每一层的责任:
① 用户:"明天北京天气怎么样?"
② 你的代码拼装上下文(system + user)→ 发给模型
③ 模型返回:{"tool_calls":[{"name":"get_weather","arguments":{"city":"北京","date":"明天"}}]}
└─ 模型的工作到此结束
④ 你的代码:
a. 校验参数(city 存在?date 格式对?)
b. 检查权限(当前任务允许调 get_weather?)
c. 分级(读操作,直接执行)
d. 真的发起 HTTP 请求
e. 归一化结果(去掉时间戳、固定字段顺序)
⑤ 你的代码把结果追加为一条 tool 消息
⑥ 你的代码再次调用模型 → 模型生成自然语言回答
⑦ 你的代码把回答返回给用户
数一下:模型只参与了第 ③ 步和第 ⑥ 步。其余六步全在你的代码里。
这就是 Agent 的真实形态——它是你的程序在驱动模型。模型只是其中最聪明的一个零件。
五、这个认知带来的三个实际好处
好处一:安全边界画得清
不要再问"怎么让模型不干坏事",而要问:
我的工具层允许模型做什么?
把工具清单、参数白名单、审批规则列出来,风险就可控。模型在这个边界内怎么"自由发挥"都不会造成损害。
好处二:调试方向明确
Agent 出错时,按这个顺序查:
① 上下文对不对?(缺信息?混进错信息?)
② 模型判断对不对?(换更强模型试)
③ 工具实现对不对?(单独测工具)
④ 参数校验够不够?(看传进来的参数)
绝大多数问题在 ① 和 ③,而不是 ②。
好处三:可以渐进增强
因为模型只负责"判断",你可以逐步增强外围而不用改模型:
- 加检索 → 模型知道更多
- 加记忆 → 模型记得更久
- 加状态栏 → 模型数得更准
- 加校验 → 模型出错的后果更小
每一步都是外围工程,风险低、可回滚。
六、第 2 章小结
五篇讲完,把模型这一侧的全部约束压缩成一张表:
| 约束 | 本质 | 工程应对 | 详见 |
|---|---|---|---|
| Token 计量 | 按 Token 计费,中文密度更高 | 估算成本、压缩中文 | 2.1 |
| 上下文窗口 | 输入+输出有硬上限;检索强、提炼弱 | 分层装、状态外显、压缩 | 2.2 |
| 幻觉 | 优化"像不像"而非"真不真" | 给材料、要引用、允许说不知道 | 2.3 |
| 采样随机 | 从概率分布抽样 | 输出类环节降温度;查输入噪声 | 2.4 |
| 不能执行 | 只输出文本,执行在外围 | 工具层做校验、分级、归一化 | 2.5 |
三条最重要的结论:
- 模型是零件,不是系统。 决定 Agent 可靠性的是外围工程。
- 上下文是天花板。 提效果先补上下文,再考虑换模型。
- 风险在执行层。 防线的位置在工具与权限,不在提示词。
七、工具设计上的五个具体判据
上一节讲了工具层"该承担什么",这一节把它落成可检查的判据。每个都来自真实系统的教训:
| # | 判据 | 反例 | 后果 |
|---|---|---|---|
| 1 | 一个工具做一件事 | manage_user(action, data) 一个工具干增删改 |
参数组合爆炸,模型容易传错 |
| 2 | 参数尽量用枚举,不要用自由文本 | path: "随便填个路径" |
越权访问任意文件 |
| 3 | 读与写必须分成两个工具 | query(sql) 里允许 UPDATE |
无法单独对写操作加审批 |
| 4 | 返回值要小且稳定 | 直接返回 500KB 的 JSON | 撑爆上下文、顺序不稳 |
| 5 | 错误信息要可行动 | 抛裸异常 | 模型无法自我纠正 |
第 2 条和第 3 条尤其重要,它们直接决定第 14 章的权限模型能不能落地:
- 枚举参数让"允许的范围"可以被静态定义
- 读写分离让"不可逆操作"可以被单独拦截
一个常见的错误设计
❌ exec_command(cmd: string)
这一个工具等于把整个 shell 交给模型。它极其灵活,也极其危险——你无法对它做参数校验,也无法分级副作用。
更稳的做法是拆成一组合法的具体动作:
✅ read_file(path) // 读,白名单目录内
✅ write_file(path, text) // 写,需审批
✅ run_test(name) // 跑测试,固定白名单
✅ git_diff() // 只读状态
这看起来限制了能力,实际上反而让 Agent 更可靠——因为每个动作的边界清晰,模型不容易用错,你也容易拦截。
第 9 章要讲的 Skills、第 4.5 节的「工具爆炸」,本质都是这个思路的延伸:用更少的、更明确的接口,替代更少的约束。
八、常见误区补充
误区六:工具越多,Agent 越强。
工具数量增加会挤占上下文(2.2)、增加选择错误概率。克制是工具设计的第一原则(详见 4.5)。
误区七:模型输出的 JSON 一定合法。
不一定。尤其在长输出、嵌套结构、含特殊字符的场景下,它会漏括号、多逗号。必须有 schema 校验与重试机制(详见 3.2)。
误区八:工具报错就等于 Agent 失败。
恰恰相反,结构化、可读的错误信息是 Agent 自我纠错的关键输入。报错要报得"有营养"。
九、动手:一个最小工具层的完整实现
把"工具层要做五件事"落到实处。
def execute_tool(call, registry, policy):
# ① 工具存在?
tool = registry.get(call.name)
if not tool:
return tool_error(f"未知工具:{call.name}。可用工具:{list(registry)}")
# ② 参数校验(结构)
missing = [k for k in tool.required if k not in call.args]
if missing:
return tool_error(f"缺少必填参数:{missing}")
for k, spec in tool.schema.items():
if k in call.args and not check_type(call.args[k], spec.type):
return tool_error(f"参数 {k} 应为 {spec.type}")
# ③ 语义校验(枚举/范围/白名单)
err = tool.semantic_check(call.args)
if err:
return tool_error(err)
# ④ 权限与副作用分级
if not policy.allows(tool.name):
return tool_error(f"当前任务不允许调用 {tool.name}")
if tool.side_effect == "irreversible" and not call.approved:
return tool_need_approval(tool.name, call.args)
# ⑤ 执行 + 归一化
try:
raw = tool.run(**call.args)
return tool_ok(normalize(raw, tool.output_schema))
except Exception as e:
return tool_error(tool.explain_error(e))
五个返回类型很关键
| 返回 | 模型的反应 |
|---|---|
tool_ok |
继续 |
tool_error(可纠正) |
修正参数重试 |
tool_need_approval |
停下来等人 |
tool_denied |
换方案或放弃 |
tool_unavailable |
跳过这个工具 |
区分这五种,比统一返回"错误"有用得多——因为它们对应完全不同的模型行为。
最常见的三个实现漏洞
| 漏洞 | 后果 |
|---|---|
| 只在工具内部校验,不在调度层校验 | 校验被绕过 |
normalize 没做(直接返回原始 JSON) |
上下文膨胀 + 顺序不稳 |
explain_error 直接返回异常文本 |
模型无法自我纠正 |
这三个漏洞,本质都是"把该在工具层做的事推给了模型"。
十、把"模型不能动手"变成架构约束
理解这一点,最实际的用法是在架构上把它固化。
三条可写进设计文档的约束
① 任何"执行"动作必须经过工具层,禁止任何"直接解析模型输出后执行"的捷径。
② 工具层必须独立于模型(可单测、可 mock、可在无模型情况下跑)。
③ 不可逆动作必须有第三道闸(工具层校验 + 审批 + 幂等)。
第 ② 条常被忽略但极其重要:如果工具层无法脱离模型单独测试,你就永远不知道"出错是模型选错了,还是工具实现错了"。
一个反例
# 错:把模型输出直接丢给 eval
result = eval(model_response)
这是最危险的写法,因为它把"概率生成器"直接接到"任意代码执行"。
正确写法是只接受枚举的动作:
allowed = {"read": do_read, "write": do_write, "search": do_search}
action = model_response.get("action")
if action not in allowed:
return error("不支持的动作")
return allowed[action](**model_response["args"])
这两段代码的区别,就是"Agent 安全"与"事故"的区别。
十一、第 2 章小结(模型边界部分)
五篇的核心可以压成一句:
模型是一个"读上下文、写文本"的纯函数;所有能力靠外围工程补。
| 约束 | 应对 |
|---|---|
| 只预测 Token | 补材料(RAG)、要引用 |
| 上下文有限 | 分层装、状态外显、压缩 |
| 会幻觉 | 给证据、允许说不知道、外部校验 |
| 采样随机 | 降温度、修顺序与噪声 |
| 不能执行 | 工具层校验 + 权限 + 审批 |
这五条构成了后面所有章节的地基。
十二、下一篇
第 2 章讲的是"模型的边界"。从第 3 章开始,我们正式进入"怎么在这个边界内把事做成"。
第一个要讲的,是与模型沟通的最基本手段——提示词。它不是"说话的艺术",而是一套有明确机制、能预测失败模式的工程方法。