工具调用日志要记什么?怎么避免日志拖垮业务?
简化版
每次工具调用记一行:属于哪次运行(运行 ID、步骤 ID)、调了哪个工具(编码和名称的快照)、入参、返回、条数、成功还是失败、失败原因、耗时、时间。三条原则:成功失败都记,只记成功的话成功率永远是 100%,日志就没了排查价值;日志只是留档,不能改变业务结果:写日志失败不能让工具调用失败,记完失败日志也要把原来的异常继续抛出去,不能吞掉;控制体积:返回内容和错误信息按列宽截断,工具编码和名称在日志里存一份快照,工具被改名或删除后历史日志仍然能读。统计时注意口径:从没调用过的工具成功率应显示为空,而不是 100%。
详细版
tool_call_log
run_id 属于哪次 Agent 运行(手动调试时为空)
step_id 对应哪一个步骤
tool_code 工具编码快照
tool_name 工具名称快照
request_params 模型或管理员传入的参数 JSON
response_result 返回给模型的内容(截断)
row_count 返回条数
success 成功 / 失败
error_message 失败原因(截断到列宽)
cost_millis 耗时
create_time 调用时间
| 原则 | 做法 | 不这样做的后果 |
|---|---|---|
| 成功失败都记 | 两条路径都写日志 | 成功率失真,失败无从排查 |
| 日志不改变业务结果 | 写日志的异常自己吞掉;工具的异常照常上抛 | 日志库抖一下,Agent 整轮失败;或者工具失败被悄悄吞掉 |
| 控制体积 | 返回和错误按列宽截断 | 超长写库报错,日志反而没了 |
| 快照 | 日志里冗余编码和名称 | 工具改名或删除后日志看不懂 |
| 统计口径 | 零次调用成功率为空 | 「没调过」和「全成功」混在一起 |
完整版教学
一、工具调用日志回答哪些问题
Agent 出问题时,排查几乎都从工具调用开始:
模型为什么查不到? → 看它传了什么参数
模型为什么答错了? → 看工具返回了什么,模型是不是读错了
这次运行为什么这么慢? → 按耗时排序,看哪个工具慢
哪个工具最不靠谱? → 按工具统计失败率
改了工具说明有没有效果? → 对比改动前后的调用次数和参数错误率
这些问题决定了日志要记的字段:入参和返回用来看「模型看到了什么」,成败和原因用来统计,耗时用来找慢点,运行 ID 用来把一次执行的所有调用串起来。
二、成功失败都要记
一个常见的错误是只在成功路径写日志:
Object result = dispatch(toolCode, params);
logService.recordSuccess(...); // 抛异常时走不到这里
return result;
工具失败时一行日志都没有,统计出来的成功率是 100%。正确的写法是成功和失败都进同一个写入口:
try {
Object result = dispatch(toolCode, params);
log.success(result, cost());
return result;
} catch (Exception e) {
log.failure(e.getMessage(), cost());
throw e; // 记完继续抛,不吞异常
} finally {
logService.record(log); // 成功失败都只写一行
}
注意 throw e:日志只是留档,工具失败了,调用方(框架或 Agent 编排)仍然需要知道它失败了,才能决定是把错误交给模型、还是中断运行。
记忆钩子:日志要「全记」,但不能「改结果」。记完失败照样抛,写日志失败自己咽。
三、写日志失败不能拖垮业务
反过来,日志表本身也可能出问题:字段超长、库连接抖动。如果写日志的异常一路上抛,工具明明查到了数据,这次调用却失败了,Agent 可能因此整轮中断。所以写日志这一步要自己兜住:
public void record(ToolCallLog log) {
try {
log.setErrorMessage(StrUtil.maxLength(log.getErrorMessage(), 500));
mapper.insert(log);
} catch (Exception e) {
logger.warn("工具调用日志写入失败", e); // 只记警告,不影响工具返回
}
}
两条规则并不矛盾:工具的异常要上抛(它是业务结果),日志的异常要吞掉(它不是业务结果)。
四、体积控制:截断要按列宽
一次知识库检索可能返回十几个片段,拼起来几十 KB;报错信息可能带着整段堆栈。估算一下:
一次检索返回 15 个片段 × 每段约 800 字 ≈ 12000 字
一次 Agent 运行调用 20 次检索 ≈ 24 万字,只是这一次运行的日志
错误信息列是 varchar(500),一段堆栈几千字,不截断直接写库报错
所以每个长字段都要按列宽截断:返回内容给一个上限(比如 6 万字),错误信息截到 500 字。截断的目的是让日志一定写得进去,完整内容如果真的需要,可以存对象存储再在日志里放引用。
五、快照:日志要能独立看懂
日志里只存工具 ID、展示时联表查名称,工具被改名或删除后,历史日志就变成一串看不懂的数字,甚至联表查不出来。所以日志里冗余存一份调用当时的工具编码和名称:
| 存法 | 工具改名后 | 工具删除后 |
|---|---|---|
| 只存 tool_id,展示时联表 | 显示新名称,和当时不一致 | 联不出来,显示空白 |
| 冗余存编码和名称快照 | 显示调用当时的名称 | 仍然可读 |
入参和返回同理:存原文,不要只存摘要,排查「模型为什么查不到」时第一个要看的就是它传了什么。
六、按运行串起来,按工具统计
日志表的两个主要查法决定了索引:
按运行查:这一次 Agent 运行调了哪些工具、顺序如何 → run_id 索引
按工具查:这个工具最近的成功率、平均耗时 → tool_code 索引
运行 ID 为空的记录来自管理端手动调试,和 Agent 自动调用区分开,统计时可以按需要排除。
七、统计口径:「没调过」不等于「全成功」
计算成功率时,分母为 0 的情况要单独处理:
| 工具 | 调用次数 | 成功次数 | 错误口径 | 正确口径 |
|---|---|---|---|---|
| A | 100 | 100 | 100% | 100% |
| B | 0 | 0 | 100%(或报错) | 空(显示为 -) |
| C | 40 | 34 | 85% | 85% |
B 从没被调用过,可能是说明写得不好、模型从来不选它,这恰恰是需要关注的信号;把它显示成 100% 就把问题藏起来了。
八、常见误区与追问
- 误区:只记成功的调用就够了。 失败才是排查的重点,只记成功会让成功率永远是 100%。
- 误区:记了失败日志,异常就可以吞掉。 日志只是留档,工具失败必须照常抛给调用方处理。
- 误区:写日志失败也应该报错。 日志不是业务结果,写日志失败只记警告,不能让工具调用跟着失败。
- 误区:日志字段存 ID,展示时联表就行。 工具改名或删除后历史日志会看不懂,要存编码和名称快照。
- 误区:返回内容原样写库最完整。 超长内容会让写库直接失败,要按列宽截断。
- 追问:从没调用过的工具成功率怎么显示? 显示为空,和「调用过且全部成功」区分开,没被调用本身就是需要关注的信号。
- 追问:手动调试工具产生的日志怎么和 Agent 调用区分? 手动调用没有运行 ID,统计 Agent 行为时可以按运行 ID 是否为空区分。
九、加强记忆
工具调用日志每次调用记一行:运行 ID、步骤 ID、编码和名称快照、入参、返回、条数、成败、原因、耗时。成功失败都记,否则成功率永远 100%;记完失败照样抛,工具异常是业务结果;写日志失败自己吞,只记警告,不拖垮业务。长字段按列宽截断保证写得进去。按运行 ID 串一次执行,按工具编码做统计。零次调用的成功率显示为空,没被调用本身就是信号。
项目实战落地
项目里怎么做的
《AI Agentic RAG高级企业知识库平台》的工具统一走 ai/tools.py 里的 invoke 执行并记日志:
- 成功失败都记:成功写入返回内容,失败写入失败原因;
except分支记完日志后raise,异常继续传给调用方,日志不吞异常; - 截断:
response_result是longtext,但一次检索返回十几个片段可能有几十 KB,写库前截到 60000 字;error_message是varchar(500),同样截断; - 中文可读:序列化时
ensure_ascii=False,日志页面上直接能读,不是一串\uXXXX; - 关联运行:日志带运行 ID 和步骤 ID,关联到 Agentic RAG 执行链路的
agent_run、agent_step;日志页可以按工具、按调用结果、按运行 ID 筛选; - 成功率口径:按工具统计成功率时,从没调用过的工具返回空,页面显示为破折号。
《AI Agent 智慧医院智能导诊就诊系统》的 mcp_tool_call_log 冗余存了工具编码和名称,工具后来被改名或删除,历史日志仍然能看懂,查询也不用联工具注册表;手动执行时运行 ID 和步骤 ID 为空,一眼能分出哪些是调试、哪些是 Agent 自动调用。
为什么这样取舍
- 失败也要记:只记成功的话成功率永远是 100%,日志就失去了排查价值。
- 零次调用不显示 100%:「从没调过」和「调了 100 次全成功」是两件完全不同的事。
面试官还会追问
- 调用日志表为什么建按运行 ID 和按工具编码两个索引?分别对应什么查法?
- 知识库检索工具返回给模型时,为什么只保留片段 ID、文档 ID、分数和正文四个字段?
学完《AI Agentic RAG高级企业知识库平台》,上面这些追问你都会迎刃而解。