11 工具调用与运行时:模型提议,代码执行
·1215 字·3 分钟
结论先说:Tool Calling 的本质不是“模型能调用函数”,而是“运行时收回执行权” #
一个可靠的 Agent,不会让模型直接调用数据库、文件系统或外部 API。模型只能提出:
- 要调哪个工具
- 参数是什么
- 预期得到什么结果
真正决定“能不能执行”的,是运行时。
一、最小模型:Tool Calling 应该拆成哪几步 #
def dispatch(tool_name: str, args: dict, runtime: Runtime) -> ToolObservation:
if tool_name not in runtime.available_tools:
return ToolObservation(error="Tool not allowed")
validation_error = runtime.validator.validate(tool_name, args)
if validation_error:
return ToolObservation(error=f"Args invalid: {validation_error}")
if not runtime.authorizer.check(tool_name, runtime.identity):
return ToolObservation(error="Permission denied")
if runtime.budget.remaining < runtime.costs[tool_name]:
return ToolObservation(error="Budget exhausted")
return runtime.tools[tool_name](args)
这段伪代码就是 Tool Calling 最该记住的骨架:
- 工具名验证
- 参数验证
- 权限验证
- 预算验证
- 执行
- 返回结构化 observation
二、为什么“模型提出动作,代码执行”是唯一靠谱路线 #
如果你直接把数据库连接、文件句柄或 shell 暴露给模型,问题马上就会出现:
- 模型可能调错工具名
- 模型可能拼出非法参数
- 模型可能绕过权限边界
- 模型可能把同一个昂贵工具反复调用
所以真正的 Tool Calling,不是“让模型更强”,而是:
让模型负责语义选择,让代码负责系统边界。
这也是为什么我更喜欢把 Tool Calling 理解成“动作提议系统”,而不是“函数调用能力”。
三、运行时真正要保护的四个边界 #
3.1 工具名白名单 #
if tool_name not in runtime.available_tools:
return ToolObservation(error="Tool not allowed")
这一步防的是模型幻觉:
- 编了一个不存在的工具
- 尝试调用当前会话没暴露的工具
3.2 参数契约 #
validation_error = runtime.validator.validate(tool_name, args)
这一步防的是:
- 参数类型不对
- 必填字段缺失
- 枚举值非法
- 动态字段越界
3.3 权限边界 #
if not runtime.authorizer.check(tool_name, runtime.identity):
return ToolObservation(error="Permission denied")
这一步的核心思想是:
权限不是 prompt 规则,而是运行时硬约束。
3.4 预算边界 #
if runtime.budget.remaining < runtime.costs[tool_name]:
return ToolObservation(error="Budget exhausted")
预算控制不是附加功能,而是防止 Agent 在失败路径里把自己烧穿。
四、为什么返回值必须结构化,而不是自然语言 #
很多早期 Agent 系统喜欢返回:
工具执行失败了,因为参数错误。
这对人类看着友好,但对下一轮模型规划很差。真正可用的返回应该是:
ToolObservation(
tool_name="query_asset_db",
status="error",
error_code="invalid_args",
error_message="field 'ip' missing",
result=None,
)
这样下一轮模型看到的不是一句模糊抱怨,而是:
- 哪个工具失败了
- 为什么失败
- 失败属于哪一类
- 有没有结果
这就是为什么 Artifact / Observation 在 Agent 系统里很重要。结构化结果是多步协作的基础。
五、缓存与幂等:为什么同一个工具不该一遍遍重调 #
运行时除了验证,还应该考虑:
- 同一工具 + 相同参数是否可复用结果
- 有副作用操作能否安全重试
cache_key = hash((tool_name, normalize(args)))
if cache_key in runtime.cache:
return runtime.cache[cache_key]
这个设计的好处:
- 降低重复调用成本
- 防止模型在循环里反复打一模一样的外部请求
坏处也有:
- 需要决定缓存粒度
- 对时间敏感数据不一定安全
所以缓存不是默认打开的“优化”,而是运行时策略的一部分。
六、失败路径:真正该测的是坏情况 #
Tool Calling 最有价值的测试,不是“工具成功执行”,而是:
- 非法工具名是否被拒绝
- 参数错误是否被结构化返回
- 权限不足是否阻断执行
- 预算不足是否提前停止
- 工具异常是否不会炸穿整个循环
def test_invalid_tool_rejected(): ...
def test_invalid_args_return_structured_error(): ...
def test_permission_denied_blocks_execution(): ...
def test_budget_exhausted_stops_call(): ...
这些测试比一段“最终输出很漂亮”的 demo 更能说明系统是否可靠。
七、这一篇真正要记住的框架 #
Tool Calling 的知识框架可以压成一句话:
模型负责提出动作,运行时负责验证动作,工具负责执行动作,结构化 observation 负责把结果带回下一轮。
对应链路就是:
LLM plan
-> runtime validate
-> tool execute
-> observation record
-> next-step planning
如果没有中间这层 runtime,Tool Calling 本质上就只是“把系统权限交给模型试运气”。
八、验证命令 #
pytest tests/ -k "runtime or tool or permission or budget" -v