上周半夜,我被监控群的一条消息吵醒。用户什么都没干,只是在产品里问了一句“今天晚上会下雨吗”,AI 却答非所问地回了一句“好的,已经帮您打开卧室的台灯”。后台调用记录显示,模型把查询天气的函数和开关台灯的函数搞混了。这不是模型智商问题,而是我一直在逃避的一个工程话题:AI 函数记录。
我说的 AI 函数记录,不是给函数写注释那么简单。它是两条线:一条是给模型看的函数元信息注册表,让模型知道有哪些函数、什么时候该调、参数怎么填;另一条是给人看的调用追踪日志,让开发者知道每个函数被谁、在什么上下文里、按什么参数调用过,成功了还是失败了。这两条线缺一条,AI 应用就会从工程问题变成玄学问题。这篇文章就围绕这两条线,把我实际跑过的记录设计、踩过的坑以及基于记录做优化复盘,完整地梳理一遍。
1. 先搞清楚:AI 函数记录的“记录”到底记什么
1.1 为什么 AI 应用比传统系统更需要函数记录
传统后端接口被调用,我们看一眼请求日志就能定位问题,因为调用方是人或者前端代码,逻辑是确定的。AI 应用不一样,调用函数的“调用方”是大模型,它输出什么参数、选哪个函数,本身是一个概率事件。同一个问题,早上问和晚上问,结果可能都不一样;同一个函数描述,换一个模型版本,被调用的频率可能直接翻倍。
这就带来一个传统日志体系覆盖不到的盲区:传统日志能告诉你“调用了什么”,但很难告诉你“模型为什么这样调”。要回答“为什么”,你至少需要知道当时模型看到了什么函数定义、用户说了什么话、上一次工具调用的返回结果是什么。这一整段信息链,就是 AI 函数记录需要承载的核心内容。
这里也解释了为什么很多人照着传统接口文档的写法给模型塞函数说明,效果总是不对。因为模型不是按编译器的规则来消费函数定义的,它是按语言模型的语义相似度来选函数、填参数的。你需要为模型专门准备一套“函数使用说明书”,同时为人类开发者准备一套“调用事故还原记录”。这两者合起来,才是一个完整的 AI 函数记录体系。
1.2 大多数团队只做了一半
我在和同行交流时发现一个很普遍的状态:很多项目已经接入了大模型的工具调用能力,函数定义也写了,调用日志也打了一堆,但真正出问题的时候,依然找不到原因。
一类团队只做了“函数注册”,把函数名和参数说明发给模型,但模型在真实对话里怎么选、选得准不准、参数填得对不对,完全没有度量。用户反馈一句“AI 乱调用”,开发者只能靠猜。
另一类团队只做了“调用日志”,每个函数调用都打了点、记了时间,但记下来的字段全是后端视角的,没有把“模型的思考材料”记录下来,也就是函数定义和对话上下文。结果就是你知道某个函数天天被调用,却不知道为什么被调用。
我自己的体会是:AI 函数记录的完整形态,是一张双向的网。注册表是“告诉模型应当怎么调”的正向信息,调用追踪是“记录模型实际调成什么样”的反向信息。把这两份数据叠在一起,才能形成一个不断改进的闭环。这也是后面所有优化动作的前提。
2. 给 AI 看的记录:函数注册表的设计直接决定调用准确率
2.1 字段设计:不是把函数源码塞给模型就行
有些开发者图省事,直接把 Python 函数的 docstring 和函数签名自动转成 JSON,发给模型。短平快,但效果通常很糟,因为源码文档里充满了实现细节、内部状态、错误处理逻辑,这些对模型来说是噪音。
我在模拟项目X里维护的注册表,每个函数都对应一个 FunctionRecord 结构,核心字段如下:
| 字段 | 作用 | 为什么必须存在 |
|---|---|---|
| name | 模型用来识别函数的唯一标识 | 必须全小写加下划线,不能有标点、空格,不能出现两个语义近似的名字 |
| description | 告诉模型“什么时候该调用、什么时候不该调用” | 模型选函数的唯一依据,写得好不好直接决定选择准确率 |
| parameters | 参数名、类型、必填约束、格式示例 | 决定模型能不能正确填参,比普通接口校验严格得多 |
| returns | 返回值的结构和含义 | 模型要靠这个信息决定如何把结果组织成回答 |
| examples | 1-2 个高质量调用示例 | 少样本示例远胜过长篇描述,能有效纠正理解偏差 |
| version | 当前函数定义的版本号 | 在线会话可能还在用旧定义,排查时需要区分版本 |
| enabled | 是否在本次请求中开放给模型 | 做灰度、做限流、做 A/B 时用来动态控制暴露范围 |
字段列表看上去不复杂,但每一条我都踩过实实在在的坑。
name 字段是第一道坎。我们早期上线过一个函数叫check_pay,后来业务调整又加了一个get_payment_status,两个函数做的事情高度重叠,模型在用户问“订单支付了吗”的时候经常随机选一个。调用记录里一眼看去,两个函数的调用比大概 6:4,说不清谁对谁错。后来我把旧函数标记为deprecated,description 里明确写“该函数已废弃,请优先使用 get_payment_status”,比例才慢慢稳定下来。
2.2 description 的正确写法:动词开头、场景限定、带否定边界
注册表里最容易出问题的字段是 description。我一开始写的都是“获取天气信息”“设置灯光亮度”这种极简风格,后来发现模型在容易混淆的场景下选择准确率特别低。
吃过亏之后,我把 description 的写法固定成一套模板:触发场景 + 动作 + 参数要求 + 否定边界。举个例子:
- 反面写法:“获取天气信息”
- 正面写法:“查询指定城市未来 N 小时的天气预报。当用户询问天气、降雨、温度、风力等天气相关问题时调用。参数 city 必须使用用户直接给出的城市名,不要自行推断。如果用户只是问当前时间,不要调用此函数。”
注意最后一句“不要调用此函数”,这类否定边界对模型的约束非常有效。模型是纯概率输出,你只写“可以做什么”,它会倾向于把所有沾边的问题都套进去;你明确写出“不做什么”,才能帮助它做排除。
description 还要注意一点:不要让措辞过度承诺能力。比如你有一个函数能查询库存,但只支持当前的仓库,结果 description 里写成了“查询全国任意地区仓库库存”,模型就真的敢在用户问偏远地区库存时去调用它,然后函数在后端抛出一串校验错误。记录里你会看到大量调用失败,而这些失败本质上不是代码 bug,是描述写得太宽。
2.3 参数 Schema 里的隐性纪律
模型填参数是一个文本生成任务,不是真正的类型检查。所以参数 Schema 的设计原则和给后端接口写参数定义完全不同。
第一,参数名本身要具备自解释能力。loc这种缩写会让模型猜,city_name这种完整单词就不用猜。第二,格式要求不能只写在 description 里,最好写进参数的examples和enum字段。第三,必填字段尽量少,模型只要漏填一个必填字段,整个函数调用就报废;能让后端兜底的参数就设成可选。第四,枚举值如果可能和用户口语不一致,要么在enum里映射,要么在后端做归一化,不要把“把中文城市名转成英文编码”这种标准化工作交给模型。
参数校验失败的调用是调用记录里最有价值的一类数据。它们意味着模型“知道该用这个函数,但具体怎么用没搞懂”。每一个高频校验错误,都是一条函数定义需要修改的证据。所以我在注册表里有一套配套逻辑:只要一次调用因为参数校验失败,这条记录会被打上parameter_error标签,并自动把模型的原始参数快照存下来。后面复盘时直接按这个标签筛错例,效率非常高。
3. 给人看的记录:调用追踪日志的字段与链路设计
3.1 一条调用记录最少要包含哪些内容
函数注册表解决的是“模型应该怎么调”的问题,调用追踪解决的是“模型到底调成了什么样”的问题。我见过很多项目,日志里打了函数名、打了耗时,却没有存当时的入参和上下文,一旦线上出问题,什么都查不出来。
我自己设计的最简调用记录,包含以下字段:
call_id:本次函数调用在模型层面的唯一 ID,要能关联到模型返回里的 tool_call_idsession_id:用户会话 ID,用来还原多轮对话上下文function_name:实际被调用的函数名request_args_snapshot:模型填写的原始参数快照,JSON 原样保存response_snapshot:函数执行后的返回结果快照error_snapshot:异常信息,包含 error_type 和错误堆栈duration_ms:函数真实执行耗时,不包括模型生成时间model_name:当时用的模型版本,不同版本的行为差异非常大prompt_version:注册表定义或系统提示词的版本号timestamp:调用时间
判断一批调用记录好不好用,就看一条记录能不能回答三个问题:模型当时看到了什么、模型选择了什么、函数执行后发生了什么。上面这些字段就是围绕这三个问题设计的。
3.2 用 request_id 把“提问-调用-返回-回答”串成一条链路
单条调用记录本身价值有限,因为 AI 的一个函数调用永远嵌在多轮对话里。用户说“帮我查一下明天的高铁票”,模型先调search_ticket,拿到结果后又可能调query_weather来决定是否提醒用户带伞,最后才组织语言回答。这里面每一步都是有关联的。
所以我会在记录里额外维护一条trace_id,它串起这样一条链路:用户提问消息 -> 模型回复(可能包含工具调用指令)-> 函数调用请求 -> 函数返回结果 -> 模型再组织语言回答。有了这条链路,回放的时候才能看到模型的完整决策过程。
这块我是吃过亏的。有一版函数定义上线后,用户频繁反馈“AI 不听话”,但单看每个函数的成功率都在 95% 以上。后来我把链路串起来回放,才发现问题不在函数执行,而在于模型在调用函数后经常不用返回结果,而是直接自己瞎编一段话,绕过了函数的输出。如果没有整条链路,这个根因永远发现不了。
3.3 错误记录不能只存一行字符串
调用日志里最容易偷懒的地方是错误记录。很多人直接在error_snapshot里写str(e),一行字符串,信息量很低。我现在的做法是把错误拆成三个维度:
error_type:错误类别,比如parameter_error参数校验失败、execution_error函数内部异常、timeout执行超时、name_hallucination模型调用了不存在的函数名error_stack:后端异常堆栈或业务错误码recovered:布尔值,标记模型在当前对话后续消息里是否自动纠正了这个错误
recovered这个字段很重要,但很容易被忽略。模型调用函数失败后,有些模型会在下一轮自动换个参数重新调用,有些模型会直接放弃、转而给用户一个含糊的回答。如果一条错误被模型自行纠正了,说明它对函数定义的理解是基本正确的,只是参数形态没对;如果模型放弃重试,那就是函数定义层面的系统性误解。这两类问题的优先级和改法完全不一样。
3.4 记录要能查、能筛、能聚合,不能是一条死日志
调用记录如果只进日志收集系统而不做结构化落库,复盘时只能靠 grep,效率极低。我建议最轻量也至少把记录写进 SQLite 表,按function_name和timestamp建索引;数据量大了以后再考虑迁到关系型数据库或专门的日志系统。记录表的 JSON 字段可以直接用 text 保存,但一定要保证存进去之前能被json.loads解析,否则后面做任何分析都是灾难。
另一个容易忽略的点是保留策略。原始调用记录非常占空间,尤其要做参数快照和上下文快照。我在模拟项目X里做的是原始数据保留 30 天,20 天以上的数据只保留聚合结果,比如按天统计的调用量、成功率、平均耗时、Top 错误分布。这样既能支持近期的事故复盘,又不会被原始日志拖垮存储成本。
4. 我是怎么靠调用记录把工具选择错误率压下去一大半的
4.1 案例一:模型总是把参数格式填错,问题出在 Schema 没给示例
模拟项目X上线第一周,调用记录里攒了大量parameter_error的错误记录。我按错误类型聚合后发现,同一个函数、同一个参数,一个月内出现了几十种不同的填法,比如参数要求传数字编号,模型有时传字符串,有时传名称,有时传一个完整的对象。
看具体调用记录才明白原因:这个参数的 Schema 只定义了类型是 integer,没有加examples,description 也没有给编号规则。模型只能根据自己的“常识”猜。我把参数定义改成下面这种结构,错误率立刻降了一截:
"city_code": { "type": "string", "description": "城市编码,必须使用标准拼音编码", "examples": ["beijing", "shanghai", "guangzhou"] }不过加了 examples 之后仍然有错,原因是用户的原始表达是中文城市名,模型需要把中文转成拼音编码,而这个转换不是模型擅长的。最后我把城市名归一化逻辑放在了函数内部,也就是模型随便传中文还是编码,函数入口统一做转换。这一步让我想明白了一个原则:凡是确定性规则,就不要依赖概率模型。
4.2 案例二:函数调用成功,用户依然不满意,问题出在返回值设计
有一段时间,模拟项目X的调用成功率很高,函数本身没有任何报错,但用户满意度一直在掉。我重新翻了完整的 trace 链路,发现了一个模式:模型调用了查询类函数,函数返回了完整结果,但模型最终回答用户时只说了一句“已经帮您查询好了”,完全没有把结果展示出来。
从调用记录里能看到,函数返回值是一个巨大的 JSON 对象,字段嵌套了三层,模型读起来很吃力,干脆就放弃了引用,选择自己组织一段模糊的回应。我把返回值结构改成三层:
summary:一句话结果,模型可以直接引用details:主要的结构化信息,供模型摘取关键细节raw:原始数据,供模型在用户追问时获取深度信息
同时在函数描述里写明:“回答用户时,必须基于 summary 字段内容展开,不得脱离实际返回数据。”这个改动上线后,记录里“函数返回成功但模型未采用”的比例下降了很多,用户满意度评分也跟着上去了。
4.3 案例三:模型过度调用函数,问题出在 description 太宽松
第三种情况是最隐蔽的。调用记录显示某个函数每天被调用上百次,但点进去逐条看,相当一部分是“不需要调用”的场景。比如用户只是自言自语说了一句“空调要是能自动关就好了”,模型就真的调用空调控制函数把空调关了。
这一类问题从成功率上完全看不出来,因为函数执行没有报错。只能靠保存完整的上下文快照来回放模型的使用场景。根因还是 description 里用了太多“可以”、“支持”、“也”这类开放表达,模型的理解是“只要话题相关就要调用”。我重新审视整批函数描述,把模糊的拓展性描述删掉,加上了更严格的触发前置条件。新版本上线后,这类虚调用明显减少,而真正需要调用时的命中率没有下降。
这三个案例的共同点在于:如果没有完整调用记录,这些问题都只能靠用户投诉来被动发现。有了结构化记录,问题是自己从数据里浮出来的。
| 现象 | 记录中看到的证据 | 根因 | 改动方向 |
|---|---|---|---|
| 参数频繁校验失败 | 大量 parameter_error,参数形态五花八门 | Schema 缺 examples 和格式说明 | 增加示例、后端归一化 |
| 调用成功但用户不满 | trace 链路显示模型未引用函数返回结果 | 返回结构复杂、缺少可引用摘要 | 返回值分层并约束引用逻辑 |
| 非必要场景也调函数 | 上下文快照显示用户并非主动要求操作 | description 过度开放 | 增加否定边界、收敛触发条件 |
5. 记录系统本身的设计:别让“记录”变成新的故障源
5.1 写记录绝不能放在同步主链路里
AI 函数调用本身已经有网络延迟,后端函数执行也是毫秒级,如果在这一次链路里再塞入一次数据库写入,一旦数据库抖动,用户感受到的就是函数调用变慢甚至失败。我见过有团队把日志写到同一台数据库表里,结果日志表锁表导致业务接口整体超时。
正确做法是把调用记录推进内存队列,由独立线程异步批量消费,写入失败只写一份本地文件兜底,不影响主流程。我实际用下来,一个简单的queue.Queue加后台线程就够撑住普通业务量。记录系统的核心目标是可靠地收集数据,而不是立刻把每条数据同步落库。
5.2 采样策略:平时采样、错误全量、灰度全量
全量记录每个函数调用,存储和成本都会很高,尤其是还要保存上下文快照。我习惯用三种策略组合:
| 情况 | 采样比例 | 说明 |
|---|---|---|
| 正常运行期 | 10%-20% | 足够观察整体趋势,低成本长期保留 |
| 调用出错或校验失败 | 100% | 错误样本是优化的核心原料,一条都不能丢 |
| 新函数上线 / Prompt 灰度 | 100% | 新版本前 1-2 天需要完整观察,避免偶发问题淹没在采样里 |
这个策略的前提是“错误全量”必须可靠。所以我让调用记录器单独拍了一份逻辑:任何error_type不为空的情况,记录会被打上优先级标签,异步消费时优先处理。这样即使正常记录的丢失率高一点,错误证据链也是完整的。
5.3 脱敏:参数快照里可能藏着用户隐私
这是最容易踩的安全底线。函数入参经常包含用户手机号、地址、身份证等敏感信息,如果直接原样写进日志表,数据安全风险很高。我的做法是:写库前强制跑一道脱敏函数,把疑似敏感字段用哈希代替,长文本只保留前 50 个字符;并且对外只暴露脱敏后的记录,原始数据需要额外权限才能访问。
这一个环节不要偷懒,因为它和事后分析能力并不冲突。分析模型填得是否准确,只需要知道字段的类型和形态是否匹配,不需要知道手机号原文是什么。
5.4 定期聚合出“函数健康度报表”
原始终端日志堆得再多,也不能直接看出问题。我每周固定跑一个聚合任务,输出一张函数健康度表:每个函数调用次数、调用占比、成功率、平均耗时、Top 错误信息、模型未引用返回值的比例。这张表基本就是我对所有函数定义的“体检报告”。
值得警惕的指标不是绝对的成功率,而是调用占比和真实需求占比是否匹配。比如某个函数调用占比超过总量的一半以上,通常说明 description 写得太宽泛,或者函数边界和另一个函数重叠,导致模型把它当成了默认选项。
6. 在模拟项目X里落地一套最简 AI 函数记录:可以照抄的骨架
回到落地层面。我把在模拟项目X里沉淀的最简实现拆成三个部件:注册表装饰器、调用记录器、注册表序列化器。这套轻量骨架可以直接复制过去改改就上线,不需要引入重量级框架。
6.1 注册表:用装饰器收集函数元信息
我用一个统一的FunctionRecord结构来定义函数,再用装饰器把元信息和真实函数绑定。
from dataclasses import dataclass, field @dataclass class FunctionRecord: name: str description: str parameters: dict returns: str examples: list = field(default_factory=list) version: str = "1.0" enabled: bool = True _REGISTRY: dict[str, FunctionRecord] = {} def register_function(record: FunctionRecord): def wrapper(func): record.func = func _REGISTRY[record.name] = record return func return wrapper注册一个天气函数时,把说明写进 description 和 parameters:
@register_function(FunctionRecord( name="get_weather", description="查询指定城市未来N小时的天气预报。当用户询问天气、降雨、温度、风力等天气话题时调用。如果用户只是问当前时间,不要调用此函数。", parameters={ "type": "object", "properties": { "city_code": { "type": "string", "description": "城市拼音编码", "examples": ["beijing", "shanghai"] } }, "required": ["city_code"] }, returns="summary字段为可直接引用的天气结论,details为逐小时数据", examples=["用户问北京天气 -> get_weather(city_code='beijing')"] )) def get_weather(city_code: str): # 后端真实逻辑 return {"summary": "北京明天晴,22度", "details": []}6.2 调用记录器:异步写库,错误优先
下面是最简的异步记录器,核心是入队和后台消费两个部分:
import queue import threading import time import json call_queue = queue.Queue() def sanitize(obj): # 简化版脱敏:敏感字段在此替换,真实场景需要更完善 return obj def log_call(function_name, args=None, result=None, error=None, session_id=None): call_queue.put({ "timestamp": time.time(), "session_id": session_id, "function_name": function_name, "args_snapshot": sanitize(args), "result_snapshot": sanitize(result), "error_snapshot": repr(error), "has_error": error is not None }) def record_worker(): while True: item = call_queue.get() # 这里写入 SQLite、日志系统或后端存储 # 如果有 has_error 标记,优先处理并全量保留 with open("function_records.jsonl", "a") as f: f.write(json.dumps(item, ensure_ascii=False) + "\n") threading.Thread(target=record_worker, daemon=True).start()注意has_error字段就是前面说的“错误全量”标记。我在消费端做到:错误记录永远优先落库,普通记录可以采样丢弃。
6.3 把注册表转成模型可消费的工具列表
真正给模型发送请求时,需要把FunctionRecord转成工具调用格式。这一步就是“给 AI 看的记录”从持久层到模型侧的出口:
def registry_to_tool_schema() -> list: tools = [] for record in _REGISTRY.values(): if not record.enabled: continue tools.append({ "type": "function", "function": { "name": record.name, "description": record.description, "parameters": record.parameters, "returns": record.returns, "examples": record.examples } }) return tools6.4 半小时之后你应该看到什么
跑通上面这套骨架之后,你马上就能做几件事:看每个函数的调用量分布、看参数错误的原始参数长什么样、按 session 回放模型选错函数的完整链路。即使不做任何 Prompt 优化,这些记录也会在下次事故排查时救你一命。
需要注意,这个骨架是“可运行的最简版”,不是生产级方案。生产环境建议把记录器升级为独立服务、接上消息队列和检索系统,但记录的数据结构设计可以保持一致,换存储介质不影响已有分析逻辑。
7. 关于记录这件事,我还想多说几句
最后集中写一点我用真金白银换来的体会。
第一,不要试图一次性把函数描述写完美。函数注册表是需要靠调用记录持续迭代的“活文档”,每次线上行为异常,都应该回到记录里找证据,再改注册表。我自己的节奏是每周固定看一眼聚合报表,顺带抽查几条错误链路,发现规律就马上修。
第二,工具函数的数量不是越多越好。我的实测感觉是,同时暴露给模型的函数超过 30 个以后,选择准确率会明显下降。超过了这个数量,就要考虑把函数按场景拆成几组,先加一个意图路由函数,再按路由结果暴露对应的子函数集合。
第三,最值得看的记录不是成功调用,而是失败之后模型自己恢复的那部分。模型能自我纠正,说明函数定义总体可用,只不过边界场景没覆盖;模型放弃重试,说明这是系统性认知偏差,优先级更高。拉着记录一条条翻,这种分类能帮你把优化方向排得清清楚楚。
AI 函数记录做到最后,本质上是把“模型行为”变成“可审计、可改进”的数据资产。项目越复杂、函数越多,这套记录的价值就越明显。如果你现在还在靠猜来排查 AI 调用问题,我的建议很直接:先把注册表跑起来,把调用记录落下来,跑几周再看,你会回来感谢这些数据的。