手写 Function Calling 完整链路¶
- ID:Q054
- 难度:进阶 / 手撕设计
- 标签:Function Calling、Tool Calling、Schema、Runtime、幂等、权限
同义问法¶
- Function Calling 的底层流程是什么?
- 模型是怎么“调用”函数的?
- 不依赖框架,如何实现工具调用闭环?
- 如何设计一个支持多工具、参数校验和错误恢复的 Tool Runtime?
面试官真正考察什么¶
这道题不是考会不会写 JSON Schema,而是看你是否理解:
- 模型并不真正执行函数,只生成结构化调用意图;
- Runtime 如何把概率输出转成受控执行;
- 参数错误、工具不存在、权限不足、超时和副作用如何处理;
- 工具结果如何回填,进入下一轮决策;
- 哪些能力应由代码保证,不能只写在 Prompt 中。
一句话结论¶
Function Calling 是模型生成“动作提案”的协议,Tool Runtime 才是动作的执行者和安全边界。完整链路是:工具发现 → Schema 注入 → 模型决策 → 解析校验 → 鉴权审批 → 幂等执行 → 结果标准化 → 回填上下文 → 继续推理。
可视化图解¶
sequenceDiagram
participant U as User
participant A as Agent Runtime
participant M as Model
participant T as Tool
U->>A: 提交任务
A->>M: Context + Tool Schema
M-->>A: ToolCall
A->>A: 校验 权限 审批 幂等
A->>T: Execute
T-->>A: ToolResult
A->>A: Event + Checkpoint
A->>M: ToolResult + Updated Context
M-->>A: Final
A-->>U: 验证后的结果
一、角色边界¶
LLM
负责:选择工具、生成参数、根据结果继续推理
Runtime
负责:解析、校验、鉴权、执行、超时、重试、审计、状态更新
Tool
负责:完成一个边界清晰的确定性能力
Environment
负责:数据库、文件系统、Git、浏览器、Kubernetes 等真实世界状态
核心原则:
模型只能提出
ToolCall,不能直接接触生产环境。
二、最小数据结构¶
from dataclasses import dataclass
from typing import Any, Literal
@dataclass
class ToolSpec:
name: str
description: str
input_schema: dict[str, Any]
risk_level: Literal["read", "write", "dangerous"]
timeout_seconds: int
idempotent: bool
@dataclass
class ToolCall:
call_id: str
tool_name: str
arguments: dict[str, Any]
@dataclass
class ToolResult:
call_id: str
status: Literal[
"success", "invalid_arguments", "not_found",
"permission_denied", "timeout", "retryable_error",
"fatal_error", "pending_approval"
]
output: Any | None
error_code: str | None
error_message: str | None
retryable: bool
call_id 非常重要。并行执行、重试、恢复和结果回填都依赖它做关联。
三、完整状态流转¶
对应流程使用 Mermaid 图解展示。
四、核心伪代码¶
async def run_tool_loop(state, model, registry, policy):
while not state.is_terminal():
context = build_context(state)
candidates = select_candidate_tools(state, registry)
decision = await model.generate(
messages=context,
tools=[tool_to_schema(t) for t in candidates],
)
if decision.final_output is not None:
if verify_completion(state, decision.final_output):
state.complete(decision.final_output)
break
state.add_feedback("完成条件尚未满足")
continue
if not decision.tool_calls:
state.fail("模型既没有最终输出,也没有工具调用")
break
for call in decision.tool_calls:
result = await dispatch_tool_call(call, state, registry, policy)
state.append_tool_result(result)
state.turn += 1
state.check_budget()
return state
工具调度:
async def dispatch_tool_call(call, state, registry, policy):
tool = registry.get(call.tool_name)
if tool is None:
return ToolResult.not_found(call)
errors = validate_json_schema(call.arguments, tool.input_schema)
if errors:
return ToolResult.invalid_arguments(call, errors)
auth = policy.authorize(
user=state.user,
tool=tool,
arguments=call.arguments,
task=state.task,
)
if auth.requires_approval:
state.pause_for_approval(call, auth.reason)
return ToolResult.pending_approval(call)
if not auth.allowed:
return ToolResult.permission_denied(call, auth.reason)
idempotency_key = make_idempotency_key(
run_id=state.run_id,
call_id=call.call_id,
tool_name=call.tool_name,
arguments=call.arguments,
)
try:
raw = await execute_with_timeout(
tool,
call.arguments,
timeout=tool.timeout_seconds,
idempotency_key=idempotency_key,
)
return normalize_tool_output(call, raw)
except TimeoutError:
return ToolResult.timeout(call, retryable=tool.idempotent)
except RetryableToolError as e:
return ToolResult.retryable_error(call, e)
except Exception as e:
return ToolResult.fatal_error(call, sanitize(e))
五、为什么必须做 Schema 校验¶
模型生成的是概率输出,即使 API 声称支持结构化输出,也不能假设永远正确。
常见问题:
- 缺少必填字段;
- 字段类型错误;
- 枚举值非法;
- 日期、路径、SQL 等格式合法但业务不合法;
- 模型调用了当前上下文中不存在的工具;
- 参数组合违反业务约束。
因此至少有两层校验:
例如 delete_file(path) 中,path 是字符串只能说明结构合法,还必须校验:
- 是否位于允许目录;
- 是否为符号链接逃逸;
- 用户是否有删除权限;
- 是否需要审批;
- 是否允许递归删除。
六、错误应该由谁处理¶
1. 参数错误¶
把结构化错误返回模型,让其修正参数:
{
"status": "invalid_arguments",
"field_errors": {
"namespace": "required",
"limit": "must be between 1 and 100"
}
}
2. 瞬时错误¶
网络抖动、限流、临时 5xx 可以由 Runtime 在明确上限内重试。不要每次重试都重新调用 LLM,否则既浪费 Token,又可能改变动作。
3. 业务错误¶
资源不存在、库存不足、权限拒绝等不应盲目重试,应作为观察返回模型,由模型决定替代方案。
4. 未知错误¶
记录完整内部错误,向模型只返回脱敏、稳定的错误码,避免泄露堆栈和密钥。
七、副作用、幂等与“幽灵成功”¶
危险场景:
解决:
- 每次调用有稳定
idempotency_key; - 工具服务端记录执行结果;
- 重试同一 key 返回原结果,而不是再次执行;
- 写操作采用 outbox、事务日志或状态查询确认;
- 无法幂等的操作默认不自动重试。
八、并行工具调用¶
模型一次可以返回多个 ToolCall,但“模型认为可并行”不等于业务上真的可并行。
Runtime 需要检查:
- 是否存在参数依赖;
- 是否读写同一资源;
- 是否有顺序要求;
- 工具是否声明并发安全;
- 是否会超出并发、配额或成本预算。
结果必须按 call_id 回填,不能依赖完成顺序。
九、工具结果如何回填¶
不要把完整日志、网页或数据库结果原样塞回 Context。
建议工具同时返回:
{
"summary": "发现 2 个 CrashLoopBackOff Pod",
"structured": {
"pods": ["a-123", "a-456"],
"namespace": "prod"
},
"evidence_refs": ["obs://run-1/tool-7/raw.json"],
"truncated": true
}
模型看到摘要与结构化字段,原始证据外置保存,必要时再读取。
十、生产级检查清单¶
- 工具是否按任务动态筛选,而非全部暴露?
- Schema 和业务校验是否分层?
- 权限是否在代码层执行?
- 写操作是否有审批和幂等键?
- 超时是否小于整轮 Agent 超时?
- 重试是否按错误类型而非统一重试?
- Tool Result 是否结构化、脱敏、可追溯?
- 是否记录工具版本、输入摘要、输出引用和耗时?
- 并行调用是否经过依赖与冲突检测?
面试口述版¶
Function Calling 不是模型真正调用函数,而是模型基于工具 Schema 生成一个结构化动作提案。Runtime 收到后要完成工具解析、JSON Schema 和业务校验、权限判断、必要审批、超时与幂等执行,再把标准化 Tool Result 以 call_id 关联回填模型,进入下一轮决策。生产环境的关键不在于把工具描述写得多漂亮,而在于 Runtime 能否把模型的不确定输出收敛为可验证、可审计、可恢复的执行。参数错误可以回给模型修正,瞬时错误优先由代码重试,业务错误作为观察让模型换策略;有副作用的操作必须有幂等键和审批,不能让模型直接执行。