做 Agent 做久了,你一定会撞上这样一个晚上。
线上跑得好好的系统,某个用户来反馈:「它今天给我的回答很奇怪。」你打开日志,把同样的输入再喂一遍想复现。结果这次它表现得完全正常。再跑一遍,还是正常。那次真正出问题的运行,像从来没发生过一样,你怎么都抓不回来。
这种 bug 折磨人,是因为它跟我们熟悉的那种根本不是一回事:
| 传统软件 bug | Agent bug | |
|---|---|---|
| 能不能复现 | 有堆栈、有步骤,照着走就出现 | 一次性的,发生过就消失 |
| 手里有什么证据 | 完整日志 | 经常只剩最后一条输出 |
| 怎么定位根因 | 单步调试 | 连那次调用序列都抓不回来 |
为什么 Agent 的 bug 这么难抓?根因就三条,但每一条都致命:
- LLM 本身非确定。 同样的输入,下一次的输出不保证一样。
- 调用序列重放不了。 agent 那一轮跑了几步、每步模型返回了什么、触发了哪些工具,全过去了。
- 中间状态没留下。 日志里大概率只有最后那条输出,中间每步模型回了什么、工具收到什么参数,根本没记。
一个反直觉的事实:唯独 LLM 这层,我们没做回放
我后来想明白一件事:整个技术栈几乎每一层都是可回放的,唯独 LLM 调用这一层,我们把它留成了不可回放的。
- 数据库挂了,有快照,能恢复到出事那一刻。
- HTTP 请求出问题,那条 curl 原样再发一次就复现。
- 分布式系统这种最难缠的,业界都在用确定性模拟去重放同一串事件。
我们对「可复现」是有执念的,也有一整套工具。可偏偏到了 agent 这儿,那个最不确定、最容易出妖蛾子的环节,我们却任由它非确定、不留痕、过完就忘。
飞机为什么要装黑匣子?恰恰因为空难罕见、严重、而且几乎不可能事后复现。你没法让飞机再炸一次给你看,所以得在它正常飞的时候就把一切录下来,出事之后靠录像倒推。
Agent 的 bug 是同一种形状的问题。所以我写了 FlightBox,给 AI Agent 装一个这样的黑匣子。项目地址:
怎么用:录、放、对、固,四步
第一步,录。 一个 with 把 agent 代码包起来,块里的调用一个都不漏,业务代码一行不动:
python
import flightbox
from openai import OpenAI
client = OpenAI()
with flightbox.record("debug-session") as rec:
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "..."}],
)
print(f"录制 ID: {rec.run_id}")
这一块里每一次 chat.completions.create(),连同完整的请求、响应、延迟、token 用量、工具调用,都写进一个本地 SQLite(默认在 .flightbox/recordings.db)。纯本地,不走云,不上报。
第二步,放。 拿到一个 run_id,用 replay 把同样的代码再包一次:
python
with flightbox.replay("abc123def4"):
response = client.chat.completions.create(...)
# 拿回来的就是当初录下的那个响应,一模一样
回放时,被接管的方法不再真打 API,而是把当初录下的那条响应原样还给你。你的 agent 在本地就变成了完全确定性的:可以在那次「奇怪的回答」上反复打断点、加日志、改逻辑,每次都精确重演同一条轨迹,直到揪出根因。这跟「再跑几遍碰碰运气」是两个世界。
跑起来是这个样子,同一段代码在 replay 下逐步命中录制、输出和首次一模一样:
scss
$ python debug.py
[flightbox] mode=replay run=abc123def4
step 1/3 chat.completions.create ↳ replayed (cache hit) 12ms
step 2/3 chat.completions.create ↳ replayed (cache hit) 9ms
step 3/3 chat.completions.create ↳ replayed (cache hit) 11ms
[flightbox] 3/3 calls replayed deterministically · 0 live API hits
最终回答:(与首次录制逐字一致)
第三步,对。 改了 prompt 或 agent 逻辑,想知道「这次跟上次哪儿不一样」:
bash
flightbox diff <run-a> <run-b>
它精确告诉你第几步、哪个字段开始对不上,不用你拿两份日志肉眼比:
vbscript
$ flightbox diff abc123def4 9f7e21aa01
step 1 request.messages ✓ identical
step 2 request.system ✗ changed
- You are a helpful assistant.
+ You are a concise assistant. Prefer tool calls over prose.
step 2 response.tool_calls ✗ 0 → 1 (search_docs)
step 3 ✓ identical
1 step diverged · first divergence at step 2 (request.system)
第四步,固(我自己最偏爱的用法)。把一次真实的、尤其是出过问题的运行,直接固化成 pytest 回放测试:
bash
flightbox export <run-id> -f pytest -o test_replay.py
shell
$ flightbox export abc123def4 -f pytest -o test_replay.py
wrote test_replay.py · 1 replay test · 3 recorded calls pinned
$ pytest test_replay.py -q
. [100%]
1 passed in 0.18s
这一步的意义在于,你抓到的那个 bug 从此变成了一条回归测试 ,以后改代码再也别想让它悄悄复活。再往大里想一层:你线上每一次有价值的真实流量都能沉淀成测试用例,回归测试集是从真实世界里长出来的,而不是你拍脑袋编的。也可以 -f jsonl 导成评测数据集。
为什么是 patch SDK 这一层,而不是 HTTP 或框架
这部分我觉得最值得说。FlightBox 去 monkey-patch 的是 OpenAI 和 Anthropic 两个官方 SDK 的方法(chat.completions.create 和 messages.create),而不是拦更底层的 HTTP,也不是耦合某个框架的内部。挑这一层有三个理由:
- 它是最窄、最稳定的那道缝。 上层框架绕来绕去,最后都会收敛到
openai.chat.completions.create这一下。 - 补一道缝接住所有框架。 LangChain、CrewAI、Pydantic AI 都从这里过,你不用理解任何一个框架的内部。
- 不会随框架升级而碎。 拦的是稳定的 SDK 公开方法,不是某框架的私有实现。
一句话:选对要拦的那一层,比拦本身更重要。
边界要说清楚
免得你期待错位。FlightBox 录的是「经过 OpenAI/Anthropic SDK 的调用」这一层的真相:
- 录得到:每次 LLM 调用的完整请求、响应、延迟、token、工具调用。
- 录不到 :你 agent 内部那些非 LLM 的随机性,比如自己代码里的
random、当前时间、某个外部接口的返回。
它解决的是「LLM 调用不可复现」这个最大的不确定性来源,不负责消灭你代码里所有的不确定。把这条记住,它就是个非常趁手的东西。
和 AgentProbe 配合
如果你也在做 agent 的测试,它能和我另一个项目 AgentProbe(一个给 agent 行为做断言的 pytest 插件)串起来用:
- FlightBox 负责把真实运行录下来、变成确定性的回放。
- AgentProbe 负责在这条确定的轨迹上断言:它该调几次工具、该不该走某条分支。
一个管录下真相,一个管在真相上立规矩。
如果这篇说中了你某个调试到崩溃的夜晚,去仓库点个 star,或者提个 issue 告诉我你最想录下来的是哪一类调用。
项目地址:github.com/he-yufeng/F...
我是何宇峰,在做 AI Agent 相关的工程和开源。如果觉得有用:
- 我的更多开源项目在 GitHub:github.com/he-yufeng
- 我目前在看实习 / 秋招机会,欢迎内推或聊聊:www.linkedin.com/in/yufenghe
- 个人博客:he-yufeng.github.io