从 Function Calling 到架构思考:我在家撸代码时踩的那些坑
辞职三个月了,每天在家远程接点小活,顺便读读书、翻翻开源项目源码。说实话,刚离开大厂那会儿有点焦虑——毕竟习惯了高强度的迭代节奏和“老板盯着你交周报”的压迫感。但慢慢发现,这种“慢下来”的状态反而让我有机会重新思考一些之前在业务压力下草草应付的技术问题。
上周五晚上,我正窝在沙发上啃《设计数据密集型应用》(DDIA),边看边感慨:“这书里说的状态机和函数调用抽象,不就是我们现在搞的智能体系统缺的东西吗?” 灵光一闪,干脆动手重构一个之前外包项目里的 Function Calling 模块。没想到这一改,直接把我拉回了去年双11前通宵改 Bug 的噩梦现场。
起因:一个看似简单的“调用外部工具”需求
事情是这样的。几个月前接了个小项目:给一个客服对话机器人加“查订单”、“退换货”、“查物流”这些能力。听起来很常规对吧?但客户要求不能把逻辑全塞进大模型 prompt 里——他们担心 token 超限,也怕模型瞎编。于是我们决定用 Function Calling(函数调用)机制:让 LLM 决定是否调用某个工具,并生成结构化参数,后端再执行真实 API。
起初我们用了 OpenAI 的 function calling 接口,一切顺利。但后来客户要部署到私有环境,得换成本地模型(比如 Qwen、Llama3)。问题来了:这些开源模型的 function calling 能力参差不齐,有的连 JSON 格式都输出不对。更别提错误处理、重试、上下文衔接这些细节了。
当时为了赶 deadline,我们直接写了个硬编码的 if-else 分支:“如果用户说‘订单’就调 getOrder”,结果上线三天就被 QA 报了十几个 bug:
- 用户说“帮我看看我的单子”,没触发调用
- 模型返回
{"function": "getOrder", "params": {"orderId": "abc"}},但后端期望的是order_id - 更离谱的是,有一次模型自己编了个
cancelAllOrders()函数,后端还真去执行了(还好权限控制拦住了)
那一刻我真的想砸电脑。
重构思路:把 Function Calling 当成一种架构契约
冷静下来后,我意识到问题不在模型,而在我们把 Function Calling 当成了“临时补丁”,而不是系统间通信的契约。这让我想起在大厂时架构师常说的一句话:“接口即文档,调用即协议”。
于是我决定参考《企业集成模式》里的思想,把整个 Function Calling 流程拆解成几个核心组件:
- Function Registry:注册所有可用函数的元信息(名称、参数 schema、描述)
- Call Planner:由 LLM 根据当前对话和 registry 决定是否调用、调哪个、参数是什么
- Executor:安全执行函数,处理异常、重试、权限校验
- Result Integrator:把执行结果转成自然语言,塞回对话上下文
关键点在于:Planner 和 Executor 之间必须通过严格 Schema 通信,不能依赖模型“自觉”。
实战:用 Pydantic + Decorator 构建类型安全的函数注册中心
我翻了翻 LangChain 的源码,觉得太重;又看了 LlamaIndex,发现耦合太深。干脆自己撸个轻量版。
首先定义函数元信息:
from pydantic import BaseModel, Field
from typing import Callable, Dict, Any, Optional
class FunctionSpec(Base日消息):
name: str
description: str
parameters: Dict[str, Any] # JSON Schema
implementation: Callable
# 全局注册表
FUNCTION_REGISTRY: Dict[str, FunctionSpec] = {}
然后写个装饰器,自动注册函数并提取 schema:
def register_function(name: str, description: str):
def decorator(func: Callable) -> Callable:
# 用 Pydantic 自动推导参数 schema
sig = inspect.signature(func)
params_schema = {"type": "object", "properties": {}, "required": []}
for param_name, param in sig.parameters.items():
if param.annotation != inspect.Parameter.empty:
# 这里简化了,实际可结合 pydantic model
params_schema["properties"][param_name] = {
"type": "string" # 实际应根据 annotation 映射
}
if param.default == inspect.Parameter.empty:
params_schema["required"].append(param_name)
FUNCTION_REGISTRY[name] = FunctionSpec(
name=name,
description=description,
parameters=params_schema,
implementation=func
)
return func
return decorator
使用起来非常清爽:
@register_function("get_order_status", "查询用户订单状态")
def get_order_status(order_id: str, user_id: str) -> dict:
# 实际调用内部服务
return {"status": "shipped", "tracking_no": "SF123456"}
@register_function("request_return", "发起退货申请")
def request_return(order_id: str, reason: str) -> dict:
if not reason:
raise ValueError("退货原因不能为空")
return {"return_id": "RT789"}
这样,无论用什么模型,Planner 只需要拿到 FUNCTION_REGISTRY 的 schema 列表,就能生成合规的调用请求。
模型适配层:让本地模型也能“规范输出”
最大的坑其实是模型输出不可靠。OpenAI 的 function calling 返回的是结构化对象,但开源模型往往输出自由文本。
我的解法是:强制模型只输出 JSON,并用 JSON Schema 做后置校验。
先构造 prompt:
你是一个智能助手,可以调用以下工具:
{{ functions_json }}
请根据用户请求决定是否调用工具。如果需要,请仅输出一个 JSON 对象,格式如下:
{"name": "函数名", "arguments": {"参数1": "值1", ...}}
不要输出任何其他内容。
然后在解析时加一层鲁棒性处理:
import json
import re
def parse_model_output(output: str) -> Optional[Dict]:
# 尝试提取 JSON 块(兼容 markdown code block)
json_match = re.search(r"```(?:json)?\s*({.*})\s*```", output, re.DOTALL)
if json_match:
output = json_match.group(1)
try:
call = json.loads(output)
func_name = call.get("name")
if func_name not in FUNCTION_REGISTRY:
return None
# 校验 arguments 是否符合 schema
spec = FUNCTION_REGISTRY[func_name]
jsonschema.validate(call.get("arguments", {}), spec.parameters)
return call
except (json.JSONDecodeError, jsonschema.ValidationError):
return None # 触发 fallback 逻辑
如果解析失败,就走 fallback:要么让用户澄清,要么用规则引擎兜底(比如关键词匹配)。
执行安全:别让模型拥有“删库权限”
这里必须吐槽一下:很多教程只教你怎么调用,却不说权限控制。我见过有人直接把数据库 delete 接口暴露给模型,简直是拿生产环境开玩笑。
我的 Executor 加了三层防护:
- 白名单限制:只有注册过的函数才能被调用
- 参数清洗:对敏感字段做脱敏或校验(如 order_id 必须符合格式)
- 权限上下文:每个调用都带上 user_id,函数内部做 ACL 检查
def execute_function(call: dict, user_id: str) -> dict:
func_name = call["name"]
args = call["arguments"]
# 注入用户上下文(避免函数自己去取 session)
args["user_id"] = user_id
spec = FUNCTION_REGISTRY[func_name]
try:
result = spec.implementation(**args)
return {"success": True, "result": result}
except Exception as e:
# 记录日志但不暴露内部错误
logger.error(f"Function {func_name} failed: {e}")
return {"success": False, "error": "操作失败,请稍后重试"}
这样即使模型被 prompt 注入攻击,最多也只能调用有限的几个函数,且参数受控。
效果对比:重构前后的关键指标
我把新旧方案跑了一组测试(1000 条真实用户 query),结果如下:
| 指标 | 旧方案(硬编码) | 新方案(Registry + Schema) |
|---|---|---|
| 调用准确率 | 68% | 92% |
| 参数错误率 | 23% | 3% |
| 非法函数调用 | 5 次 | 0 次 |
| 开发新函数耗时 | ~2 小时(需改多处) | ~15 分钟(只需加 decorator) |
最爽的是,现在产品经理说“加个查积分的功能”,我喝着咖啡,10 分钟写完函数,自动注册、自动出 schema、自动接入对话流——再也不用担心他半夜钉钉我改 if-else 了。
一点感悟:技术债的本质是认知债
写这篇文章的时候,我又翻了翻床头那本《程序员修炼之道》。里面说:“软件开发不是写代码,而是管理复杂性。” 回想在大厂的日子,我们总被 deadline 逼着“先跑起来再说”,结果 Function Calling 这种看似简单的功能,最后变成一堆 if-else 和 magic string。
现在离职了,反而能静下心来把一个模块当成产品去做:考虑扩展性、安全性、可观测性。虽然挣的钱少了点,但每次 commit 都感觉在造轮子,而不是搬砖。
如果你也在做类似智能体、Agent 或 LLM 应用,别小看 Function Calling。它不只是个 API 调用,而是一套人与机器协作的协议。设计得好,系统健壮;设计得糙,半夜报警。
最后送大家一句我在阿里学到的话:“好的架构,能让坏程序员写出不那么烂的代码。” 而好的 Function Calling 设计,能让不靠谱的模型也乖乖听话。
(完)
P.S. 如果你对这个轻量级 Function Calling 框架感兴趣,我已经把核心代码扔 GitHub 了,搜 funcall-lite 就能找到。欢迎 issue,但别指望我秒回——我现在可是自由职业者,作息全靠心情 😎

评论 0