工具调用44 24 分钟

Function Calling 不是模型会调函数就完事:先把契约验清楚

Function Calling 要把自然语言决策变成可校验的动作请求。Schema、解析、权限和执行回执缺一环,漂亮的 JSON 也可能直接变成线上事故。

原理 实现 边界 追问

本篇阅读顺序 · 三遍读法

一篇文章,读出三种能力。

先弄懂它为什么这样工作,再把条件换一换,看方案还能不能站住,最后用代码和证据复盘一遍。顺序固定,读完才知道自己是真的会了,还是只记住了名词。

01 / 基础知识先回答“它为什么这样工作”

沿着直觉、公式和边界读正文,看到变量就问输入、状态、复杂度分别是什么。

02 / 高频追问再回答“条件变了怎么办”

把面试官的追问当作小型设计评审:更大流量、更少上下文、外部失败或安全约束出现时,局部如何重算。

03 / 从零实现把动作、回执与失败边界写进最小实现

沿代码、表格和证据卡复盘,最后用文末的 60 秒回答确认自己没有只记住名词。

问题面试官到底在判断什么
机制系统如何工作
证据代码、指标与取舍
表达30 秒回答骨架

“帮我把这笔订单退了。”客服 Agent 返回了一段 JSON,看起来字段齐全,业务却不敢执行:金额是字符串,订单号来自用户上一句里的一段引用,退款原因还是模型自己补的。这时问题不是模型“不会 Function Calling”,而是系统把一段建议误当成了已经验证的命令。

先给一个能复述的答案

Function Calling 是一个动作协议,不是模型直接执行函数。模型只负责提出结构化调用;应用层负责选择工具、校验 schema、补充身份和权限、执行副作用,再把真实回执回填给模型。可靠链路至少有五步:定义契约 → 解析调用 → 参数验证 → 权限与幂等检查 → 执行并回写结果。任何一步失败,都应该返回可解释的错误或转人工,不能让模型自行“修一修再试”。

Function Calling 从模型提议到工具回执的五段契约链路

图 1:模型产生的是候选动作,真正的执行权在应用层。

一、Schema 是给程序看的合同

工具描述不能只写“调用退款接口”。至少要说明参数类型、枚举范围、必填项、互斥关系和副作用。描述越像广告,执行层越难验证;写得像合同,错误才会在离开模型前暴露。

{
  "name": "create_refund_request",
  "description": "创建退款申请,不会直接把款项退回用户",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["order_id", "amount", "reason", "confirm_token"],
    "properties": {
      "order_id": {"type": "string", "pattern": "^ord_[a-z0-9]+$"},
      "amount": {"type": "number", "exclusiveMinimum": 0},
      "reason": {"type": "string", "enum": ["duplicate", "defect", "user_cancel"]},
      "confirm_token": {"type": "string", "minLength": 20}
    }
  }
}

additionalProperties: false 很实用。它会挡住模型随手添加的 approved: trueoperator: admin 之类字段。金额还要和订单真实金额比对,Schema 只能证明它是数字,不能证明它属于这位用户。

把语义约束写成机器能执行的规则

有些限制放不进 JSON Schema,例如“退款金额不能超过可退余额”“只能操作当前登录用户的订单”。这类规则要进入 policy 层,而不是藏在 Prompt 里:

def validate_refund(call, ctx, orders):
    order = orders.get(call["order_id"])
    if not order or order.user_id != ctx.user_id:
        return "order_not_accessible"
    if call["amount"] > order.refundable_amount:
        return "amount_exceeds_balance"
    if not ctx.has_scope("refund:create"):
        return "scope_denied"
    return None

二、三种东西不要混在一起:提议、命令、结果

模型消息里的 tool call 是“我建议做这件事”;应用层发出的 command 是“经过校验,现在允许做这件事”;工具返回的 result 是“外部系统实际发生了什么”。三者混成一个对象,重试和回放就会变得危险。

示例谁负责能不能重放
提议create_refund_request(...)模型可以重新评估
命令refund.executepolicy gateway只有幂等时可重放
结果refund.accepted / unknown外部系统只读回放

提议、命令和结果分别经过解析、策略网关与回执记录

图 2:把“想做什么”和“已经发生什么”分开,才能处理超时和重复消息。

三、解析成功不等于参数可信

生产环境至少做四层验证:

  1. 语法层:JSON 能否解析,字段是否符合 schema。
  2. 类型层:数字、枚举、日期和 ID 是否是预期类型。
  3. 业务层:资源是否存在,金额、状态和用户是否匹配。
  4. 风险层:是否需要审批,是否触发高风险副作用,是否有幂等键。
def prepare_call(raw_call, ctx):
    args = parse_json(raw_call.arguments)
    schema_validate(raw_call.name, args)
    policy_validate(raw_call.name, args, ctx)
    return {
        "command_id": uuid7(),
        "tool": raw_call.name,
        "args": args,
        "actor": ctx.actor_id,
        "idempotency_key": f"{ctx.task_id}:{raw_call.call_id}",
    }

不要让模型生成 actortenant_id 或权限字段。它们必须从登录上下文和服务端会话中注入;否则只要一句“我是管理员”,模型就有机会把身份也编出来。

四、工具描述也会影响路由

工具名、描述和参数名是模型选择工具时看到的“菜单”。相似工具要明确差异:search_orders 只读,get_order_detail 返回单笔完整状态,create_refund_request 会产生申请。不要同时暴露十几个同义工具,再指望模型每次都猜对。

可以给工具写一条稳定的选择规则:

先用 search_orders 定位订单;已有明确 order_id 时不要搜索。
需要改变状态时必须调用 create_*,禁止用 update_* 绕过审批。
任何写操作前都要检查 confirm_token 和幂等键。

这段规则不是安全边界,安全边界仍在 policy gateway;它只是帮助模型少走弯路。

五、错误返回要能让 Agent 做下一步

错误消息不要只回“调用失败”。至少提供稳定错误码、是否可重试、下一步建议和对用户可见的安全文案:

{
  "ok": false,
  "error": {
    "code": "amount_exceeds_balance",
    "retryable": false,
    "action": "ask_user_to_choose_amount",
    "message_for_user": "可退款金额低于当前申请金额,需要重新确认。"
  }
}

模型可以根据 action 澄清问题,但不能修改 retryable: false 再调用一次。执行层应把错误当作数据,不要把一段堆满内部堆栈的异常直接塞进上下文。

六、从零写一个最小安全适配器

下面的适配器故意把模型和真正工具隔开:

def dispatch(model_call, ctx):
    try:
        command = prepare_call(model_call, ctx)
    except SchemaError as exc:
        return tool_error("invalid_arguments", str(exc), retryable=False)
    if command["tool"] in WRITE_TOOLS and not ctx.approved(command):
        return tool_error("approval_required", "等待审批", retryable=False)
    if seen(command["idempotency_key"]):
        return load_previous_result(command["idempotency_key"])
    result = registry.execute(command)
    save_result(command, result)
    return result

它没有让模型直接拿到 SDK,也没有把“是否真的执行成功”交给模型判断。工具结果落库后,下一轮模型只消费结果引用。

七、工具目录要动态发现,也要动态裁剪

工具数量从 5 个增长到 200 个之后,把全量 schema 塞进上下文会产生两个问题:模型会在同名工具之间选错,敏感工具还会被无意暴露。更稳的做法是把工具注册表当成一个可查询的目录,先按任务语义召回候选,再经过租户、环境、风险和版本过滤。

{
  "name": "crm.create_refund_request",
  "summary": "创建退款申请,不直接打款",
  "capabilities": ["refund", "order"],
  "risk": "write_review",
  "schema_version": "2.1",
  "requires": ["order.read", "refund.create"]
}

动态发现不等于每轮都让模型自由搜索。目录服务应该返回稳定的 shortlist,并记录为什么这个工具可见、哪些工具被裁掉。这样线上出现误路由时,能够区分“召回错了”“策略过滤错了”还是“模型选错了”。

工具注册表经过语义召回、权限裁剪和版本协商后形成会话目录

图 3:模型看到的是当前任务的最小工具目录,而不是整个平台的后门清单。

八、契约版本要支持协商和回放

工具 schema 变更时,不能只改一段描述然后期待模型自适应。建议把版本分成三类:增加可选字段属于兼容变更;删除字段、改变枚举语义或修改金额单位属于破坏性变更;新增能力则通过 capability negotiation 明确声明。

def choose_contract(client_versions, server_versions):
    common = sorted(set(client_versions) & set(server_versions), reverse=True)
    if not common:
        raise ContractError("no_compatible_schema")
    return common[0]

def migrate_args(args, from_version, to_version):
    if from_version == "1.0" and to_version == "2.0":
        args["reason_code"] = args.pop("reason", "user_cancel")
    return args

每次 command 都要保存 schema_version、参数规范化后的快照和迁移路径。回放历史调用时使用原版本契约,不能拿今天的 schema 重新解释半年前的参数;否则审计看到的“原请求”和系统重放的“新请求”可能不是同一个动作。

契约迁移之后还要做一次“dry-run 差异回放”

迁移函数通过单元测试,不代表真实历史调用仍然得到同样的路由和拒绝结论。拿一批脱敏 trace 在旧、新契约上做 dry-run,只执行解析、schema、policy 和工具选择,不触发副作用;对比参数 hash、版本协商、拒绝原因和下游工具,任何不解释的变化都先暂停切换。

{
  "contract_diff_receipt": "cdr_20260820_56",
  "replay_set": "tool-traces-v12",
  "from_version": "1.0",
  "to_version": "2.0",
  "side_effects": "disabled",
  "same_route": 0.96,
  "changed_cases": [
    {"call_id": "call-07", "change": "reason -> reason_code", "expected": true},
    {"call_id": "call-19", "change": "amount_unit", "expected": false}
  ],
  "decision": "hold_for_amount_unit_review"
}

dry-run 的价值是把“兼容”拆成可解释的差异:字段迁移可以预期,金额单位变化不能悄悄放行;工具路由变化也要说明是目录、权限还是模型选择导致。只有所有差异都有 owner 和处置动作,才切换默认契约。

契约迁移差异回放:旧、新 schema 只做解析与策略比较,不触发真实副作用

L5:为什么 schema 迁移要做 dry-run,而不是直接线上灰度?

线上灰度仍可能触发真实写操作,出了差异再回滚已经太晚。dry-run 能在不产生副作用的前提下比较路由、参数和拒绝语义,先把真正的破坏性变化筛出来。

九、把拒绝样本纳入训练和评测

一个可靠的调用器不只要会生成正确 JSON,还要知道什么时候不应该调用。训练和评测数据至少分四类:

样本期待行为例子
valid生成可执行提议订单号明确、金额在可退余额内
incomplete先澄清用户说“把那笔退了”,没有唯一订单
denied解释拒绝原因资源不属于当前用户、scope 不足
repairable修正后再提议日期格式错误、缺少必填字段

评测时要把“没有调用”作为一种正确答案。比如用户要求导出全部客户隐私,而当前会话只有聚合统计权限,Agent 应明确拒绝并提供安全替代方案,而不是调用一个返回脱敏数据的工具后假装完成。

十、一次调用要有可回放 trace

聊天记录适合看对话,不适合还原一次线上动作。建议为每个 call 写结构化 trace:

{
  "trace_id": "tr_20260819_001",
  "call_id": "call_07",
  "proposal": {"tool": "crm.create_refund_request", "args_hash": "sha256:..."},
  "validation": ["schema_ok", "resource_ok", "approval_ok"],
  "command_id": "cmd_91",
  "dispatch": {"attempt": 1, "request_id": "req_42"},
  "outcome": "committed",
  "next": "write_receipt_to_thread"
}

Trace 让你能回答三个问题:模型当时提出了什么、应用层放行了什么、外部系统实际发生了什么。对同一 trace 做 dry-run 时,只执行 schema、policy 和路由,不触发真实副作用;这也是上线新模型前比较行为差异的安全方式。

十一、分层题库:从概念到系统设计

L1:Function Calling 和普通 JSON 输出有什么区别?

Function Calling 把动作名称和参数放进一个可路由的协议里,应用层可以按工具注册表执行、校验和审计;普通 JSON 只是文本格式,不能天然表达工具权限、回执和副作用。

L2:Schema 已经校验通过,为什么还不能直接执行?

Schema 只能证明格式和部分类型正确,不能证明资源属于当前用户、状态允许操作、金额合理或操作已经审批。还要经过业务、权限、风险和幂等检查。

L3:如何防止模型伪造执行结果?

模型永远不写最终状态。执行结果由工具适配器从外部系统读取并签名记录,模型只能引用这份回执生成解释;没有回执就不能说“已完成”。

L1:为什么工具 schema 要禁止未知字段?

未知字段可能是模型自行补出的身份、审批或金额标记。拒绝它们能让参数边界清楚,避免“看起来有用”的字段绕过服务端规则。

L1:哪些字段绝不能让模型自己生成?

主体、租户、权限 scope、环境、审批状态和最终执行结果都应由服务端上下文注入或回执产生。模型最多提出业务参数。

L1:工具错误为什么要返回稳定错误码?

稳定错误码可以驱动澄清、重试、换工具或转人工,避免模型从一段内部异常堆栈里猜下一步。

L2:工具从 10 个变成 200 个,怎么避免选错?

先按任务语义召回候选,再按命名空间、权限、风险和版本裁剪,最后只把 shortlist 交给模型;同时保留路由理由,便于复盘。

L2:schema 版本升级如何保证旧调用还能回放?

保存调用时的版本、规范化参数和迁移路径,历史回放使用原契约;破坏性变更要并行运行旧版本或明确拒绝迁移。

L2:怎样评测 Agent 是否“知道不能调用”?

在题集中加入权限不足、资源不明、审批缺失和高风险越权样本,指标不只看合法调用成功率,还要看拒绝准确率和澄清质量。

L2:为什么要把 proposal、command、result 分开存?

它们分别代表模型意图、策略放行和外部事实。分开后才能安全重试、审计差异,也不会把模型的一句建议误当成已完成。

L3:如何设计一个支持多租户的工具注册表?

注册表保存全局能力和租户策略两层数据,请求时按主体、租户、环境、数据分类和 schema 版本生成短期会话目录,不能用前端隐藏代替服务端过滤。

L3:工具回执丢失但外部操作可能成功,怎么设计?

用幂等键和 request_id 查询接口对账;在结果确认前保持 UNKNOWN,不生成已完成文案,也不允许无条件重放写操作。

L3:怎样让新模型上线前不触发真实副作用?

对历史 trace 做 dry-run,只执行解析、schema、policy 和路由,比较新旧模型的工具选择、参数差异和拒绝率,最后再用沙箱契约测试。

契约变更要做“旧调用可回放、新调用可拒绝”的双回归

工具 schema 一旦升级,最容易漏掉的不是字段名,而是旧版本调用在新服务端上会不会被悄悄解释成另一种语义。我的做法是把每次契约变更做成一张 diff 卡:旧请求用旧 schema 回放,新请求走新 schema;对删除字段、枚举收窄、默认值变化和副作用字段逐项给出结果。兼容只代表能解析,不代表可以执行,写操作仍要重新通过当前权限和业务校验。

contract_diff: cdf_20260820_12
tool: crm.create_refund_request
from: v3
to: v4
cases:
  old_valid_request: {parse: pass, policy: recheck, execute: sandbox_only}
  removed_field: {parse: reject, error_code: schema_removed}
  enum_narrowed: {parse: reject, error_code: enum_out_of_range}
  new_required_field: {parse: migrate_if_safe, otherwise: clarify}
  duplicate_idempotency_key: {parse: pass, execute: return_current_state}
gates:
  old_trace_replay: pass
  side_effect_in_live: blocked
  receipt_version: v4
decision: release_with_compat_window

工具契约变更双回放卡:旧调用回放、新调用校验、写操作只进沙箱

L5:为什么“schema diff 没有红线”仍不能直接发布?

diff 只说明结构变化,没有覆盖默认值、权限语义、幂等键和外部系统状态。发布前必须把历史 trace、边界参数和失败回执放进沙箱回放,并确认旧版本的拒绝原因没有被新版本改成误执行。

生产契约测试要覆盖“拒绝、部分成功和未知”

很多团队的工具测试只有一条 happy path:参数合法,接口返回 200,Agent 继续往下走。真正上线后更棘手的是三类灰区:服务端拒绝但已经产生部分副作用、网络超时导致结果未知、以及同一个参数在不同租户下含义不同。契约测试要把这三类状态写成可回放的 case,断言的不只是 HTTP code,还包括业务状态、幂等键和后续动作。

我会让适配器先把供应商错误归一成内部状态,再交给策略层决定 retryclarifyreconcilestop。例如创建退款单超时,不能直接重试;先拿 request_id 查当前状态,若已创建就返回原单号,若仍未知就挂起并通知人工。这样模型看到的是稳定的动作边界,而不是每家 API 各说各话。

contract_probe: ctp_20260820_31
tool: billing.create_refund
cases:
  permission_denied: {provider: 403, internal: rejected, next: clarify}
  timeout_after_accept: {provider: unknown, internal: unknown, next: reconcile}
  partial_side_effect: {provider: 207, internal: partial, next: read_back}
  same_request_id: {provider: 200, internal: committed, next: return_existing}
assertions:
  model_never_sees_provider_stack: true
  unknown_never_auto_retried: true
  idempotency_key_preserved: true
  tenant_context_server_injected: true
release: sandbox_only_until_all_cases_pass

工具契约探针:拒绝、超时、部分成功和幂等回读分别进入不同动作边界

L5:为什么“接口返回 200”仍不能算工具调用成功?

200 只代表传输层接受了请求,业务对象可能仍在处理中,甚至只完成了一半。要把“已接受、已提交、已生效”拆开记录,并用业务回读和版本号确认最终事实;否则 Agent 会把排队中的动作说成已经完成。

契约回放还要检查“默认值漂移”

字段没删、类型没变,也不代表契约真的兼容。最隐蔽的回归往往来自默认值:旧客户端省略 dry_run 时,v3 默认是 true,v4 却改成了 false;schema diff 看起来很干净,真实调用却从预览变成了写入。于是我会把“字段缺失时的默认路径”单独列为回放维度,并把是否产生副作用作为硬断言。

default_drift_probe: ddp_20260820_75
tool: crm.create_refund
cases:
  omitted_dry_run:
    v3: {resolved: true, side_effect: false}
    v4: {resolved: false, side_effect: true}
    decision: block_until_explicit
  omitted_currency:
    v3: {resolved: CNY, source: tenant_default}
    v4: {resolved: null, next: clarify}
assertions:
  defaults_logged: true
  side_effect_requires_explicit_opt_in: true
  old_trace_replay: required

工具契约默认值漂移卡:缺省字段先解析差异,再决定是否允许副作用

L5:为什么只测显式传参仍然不够?

真实 Agent 会大量省略可选字段,默认值就是它实际调用的一部分。只测“字段都填满”的 happy path,会把默认路径的语义变化藏起来;应把缺省、null、空数组和旧客户端序列化方式都纳入回放。

工具契约要同时校验 schema、语义和副作用等级

JSON Schema 能检查类型,却不能说明 delete 是否幂等、dry_run 是否真的不写入、错误码是否可重试。工具注册时应该把结构契约与语义契约放在一起,并在 CI 里跑一组最小行为探针。

tool_contract:
  contract: tfc_20260820_124
  name: archive_project
  input_schema_version: 4
  semantics:
    idempotent: true
    dry_run_is_read_only: true
    retryable_errors: [timeout, rate_limited]
    forbidden_errors: [permission_denied]
  probes:
    - malformed_input_rejected
    - dry_run_has_no_write
    - same_idempotency_key_one_effect

工具契约三层校验:结构 schema、运行语义和副作用探针一起决定能否接入 Agent

L5:为什么“Schema 校验通过”仍不能把工具交给 Agent?

因为类型正确不代表行为安全。一个字段可以是合法字符串,却指向了错误租户;一个 dry_run 可以返回漂亮 JSON,却偷偷写入审计表。只有语义探针也通过,契约才算成立。

60 秒面试回答

我把 Function Calling 看成动作协议,而不是模型直接执行函数。模型提出候选调用,应用层先做 JSON Schema、业务规则、权限、审批和幂等检查,再由工具适配器执行副作用,并把真实回执回填。提议、命令、结果分开保存,错误带稳定错误码和是否可重试,身份、租户和权限从服务端上下文注入,不能由模型生成。这样即使模型参数格式正确,也不会绕过资源归属、状态和风险边界。

带走一张检查清单

  • 工具是否有明确的 schema、必填项、枚举和副作用说明?
  • 是否把提议、命令和真实结果分开?
  • 是否有语法、类型、业务和风险四层验证?
  • 身份、租户、审批和幂等键是否由服务端注入?
  • 错误码是否能指导下一步,而不是只返回一段异常文本?

相关笔记

参考

  • AgentAlpha《Agent 岗面试宝典 v3》:工具调用章节
  • ARIS-in-AI-Offer