
两年前我自己在本地跑了个小Agent负责整理笔记、查天气、定时拉数据这类杂活。功能很快就跑通了真正让我头疼的是后面那段日子我改了一版系统提示词或者说新挂了一个工具它到底变好了还是变坏了我拿不出任何证据。今天修复了一个工具调用的参数格式错误明天却发现它在不该动手的场景里多调了两次工具但我说不清这到底是不是我改出来的问题。后来我想明白一件事Agent这种程序的Bug光看输出根本不够必须盯过程。于是我在本地搭了一套最简单的证据机制——Tool Trace工具调用追踪加上离线回归整套方案只用了4张证据表和一个SQLite文件没有引入任何重型组件。这篇文章就是把这套方案完整拆开尽量用初级也能跟上的方式讲清楚Trace到底记什么、4张表怎么设计、离线回归怎么跑、有哪些坑必须躲。如果你手头正好在搞本地Agent或者想让Agent的改动节奏重新回归理性这套东西可以直接抄作业。1. 来自一线Agent好不好不能靠肉眼判断我一开始也天真地以为Agent的测试和普通函数没区别。跑几个用例看一眼输出正确就完事。用了一周就发现这套方法在Agent身上彻底失灵。1.1 Agent开发引出的三个说不清第一个说不清是行为变化说不清。普通程序你改一个函数输入输出对不对基本能判断。Agent不是你改完提示词它回答风格变了工具调用频率也变了但你很难说清楚哪些变化是想要的哪些是副作用。有一次我为了让回答更简洁在系统提示词里加了“不要啰嗦”结果它开始跳过参数确认步骤直接拿默认值调工具输出倒是短了行为其实坏了。第二个说不清是工具调用对不对说不清。Agent写出来的工具调用是自然语言与结构化参数的混合体它可能在正确的时候调了错误的工具也可能在错误的时候调了正确的工具更常见的是参数传得“看起来合理但实际有问题”。这种问题靠人肉看日志看三五条还行看几十条的时候眼睛就花了。第三个说不清是回归效果说不清。模型升级了换了个更强但行为习惯不同的模型你的Agent到底整体变好了还是变坏了本地Agent通常依赖本地模型或API模型换版本是个很平常的操作但如果没有固定考卷你只能靠“最近用着感觉还行”这种模糊判断来做技术决策。这三个“说不清”本质上指向同一件事Agent的运行过程没有被结构化地记录更没有形成可对比、可回放的证据。1.2 日志和证据之间隔着一条很宽的河很多人包括我一开始会想不就是打日志嘛。但日志和证据之间差距非常大。日志是给人读的混合着各种格式缺字段、没关联、前后顺序靠时间戳猜。证据是给程序用的每一条记录都必须回答特定问题这次调用了哪个工具入参是什么出参是什么花了多久成功没有是哪一个Agent版本跑出来的更重要的是证据要能回放。所谓回放就是你录下了一次真实的工具调用之后可以在离线状态下原样重现那次调用的结果而不需要真的再去请求外部API、执行本地命令或者访问数据库。离线回归的基础就是这个先把带正确答案的历史会话固化下来之后每次改动Agent都用同一套历史数据去考它。所以我需要的不是一套日志系统而是一个面向回放和评测的记录模型。这个模型落到最后就是4张证据表。2. Tool Trace设计一次工具调用留下哪些证据如果只说一个关键点我会说Tool Trace是整个证据链的地基。它记录的是一次工具调用从发起到结束的完整事实而不是那种“正在调用xxx成功”的日志字符串。2.1 Trace要结构化不要记成一段话我先声明一个很容易犯的错把Trace记成人类读的一句话例如“2025-01-01 12:00:00 调用 weather_api 返回晴天”。这种日志一点用都没有。程序没办法稳定地拆分出工具名、参数、耗时、状态码回放的时候更是无从下手。真正的Trace应该是一个结构化事件包含四类信息调用定位信息call_id、session_id、message_id它属于哪次会话、哪条助手消息。调用入参信息工具名tool_name、入参arguments必须保留原始JSON。调用出参信息结果result_json、状态status、错误信息error、耗时latency_ms。顺序信息created_at、重试次数用于还原调用链路的先后。这四类信息每一条都有对应的用途。比如我想知道“为什么这个Agent回答得这么慢”看latency_ms就能定位到是某个工具拖了后腿。我想知道“它怎么突然答非所问”把message和tool_call按时间线拼接起来就能复盘。2.2 用装饰器做埋点不污染业务代码嵌入Trace最省事的方式是用装饰器把工具函数包一层。不用改工具函数内部逻辑也不用在Agent主循环里手动拼日志包一下就有。import functools import time import uuid def trace_tool(session_id, store): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): call_id uuid.uuid4().hex payload { func: func.__name__, args: list(args), kwargs: kwargs, } t0 time.time() status ok error None result None try: result func(*args, **kwargs) return result except Exception as e: status error error repr(e) raise finally: store.insert_tool_call( call_idcall_id, session_idsession_id, tool_namefunc.__name__, argumentspayload, result_jsonresult, latency_ms(time.time() - t0) * 1000, statusstatus, errorerror, ) return wrapper return decorator注意两个细节。第一arguments的payload里args和kwargs可能包含非JSON类型的对象入库前必须做一次安全序列化例如把date对象转成字符串把bytes对象做base64编码。第二finally里的result在函数正常返回时是真实结果在异常时是None两条路径都要入库。异常路径别大意记录error_message是复盘的关键。用完装饰器之后原本这个工具函数怎么调用还是怎么调用但每一次调用已经自动变成了一条结构化证据。这是性价比最高的埋点方式没有之一。2.3 出参太大怎么办截断是手段不是甩锅本地Agent经常遇到一种情况工具返回一个巨大的结果比如一次查出200行数据库记录或者读了一个几百KB的文件内容。结果全塞进表里数据库体积直线上升。我的做法是分两级处理。小结果直接存大结果截断到固定长度例如10KB以内然后把完整结果按会话ID写进单独的备份目录用文件路径代替结果内容。这样既保住了可回放性又不让SQLite被撑爆。截断的地方要小心不要截断在JSON的中间导致回放时解析失败。正确做法是先用压缩或摘要函数处理保留完整JSON结构只对超长字符串字段做省略处理。如果你发现某个工具的结果经常被截断那更值得关注的是这个工具本身的设计而不是存储方案。3. 四张证据表面向回归的Schema怎么定现在到了核心部分。整套方案只有4张表分别叫agent_session、message、tool_call、eval_record。从一开始提到4张到现在正式落地我解释一下每张表为什么必须存在以及它们如何协作。3.1 agent_session一切证据的屋顶会话表是其他所有表的屋顶它定义了一次完整的Agent运行。字段如下session_id主键每次运行一个唯一ID。agent_versionAgent代码或提示词的版本号这是回归对比的分组依据必须填。task任务描述例如“查询本周天气”。input用户输入的原文。input_hash输入的哈希值用于去重和召回。started_at / finished_at开始与结束时间用epoch毫秒避开时区问题。status会话状态running、success、failed、aborted。tagsJSON数组用于给会话打场景标签例如“需要重试”“涉及外部API”。agent_version是整个方案的灵魂字段。没有它你根本无法回答“哪个版本到底有没有更好”这个问题。我给版本号定的标准很简单一版代码加一版提示词用git commit简称加个计数例如“v1.3.2-8f3a1”。不用做得太复杂只要能稳定区分每一次改动即可。3.2 message把Agent的脑回路按时间线排开一个Agent运行过程本质上是一串消息system提示词、user输入、assistant产生的文本、assistant发起工具调用、工具返回结果。message表就是这条时间线。字段设计msg_id消息ID。session_id归属会话。seq会话内递增序号排序用不要依赖created_at原因后面讲。rolesystem、user、assistant、tool。content消息正文。tool_calls_jsonassistant消息如果发起了多个工具调用把调用列表存这里JSON格式。created_at时间戳。为什么需要这张表因为只记录工具调用的话你看不到Agent在“动脑子”时的上下文。离线回放时你必须让Agent重新面对同样的消息序列才能模拟出当时的决策环境。message表就是考场上的试卷原文。3.3 tool_call证据链里最值钱的一张tool_call是整个证据链的核心每行一条工具调用记录字段如下call_id调用ID。session_id归属会话。message_id哪条assistant消息发起了这次调用。tool_name工具名称。arguments原始入参JSON必须原样保留。result_json原始出参JSON。latency_ms耗时毫秒。statusok、error、timeout。error错误信息。created_at时间戳。在设计时要注意一次assistant消息可能同时发起多个工具调用所以tool_call和message是多对一的关系通过message_id关联。这也是为什么我在message表里留了tool_calls_json字段它用于完整复现当时assistant消息的原始结构而tool_call表则记录每个调用的具体执行结果。这一张表可以直接回答很多关键问题某个工具的平均耗时是多少成功率是多少哪类入参最容易触发错误Agent是否在一轮对话里频繁重复调用相同工具这些问题全是离线回归量化指标的数据源。3.4 eval_record让“变好了”不再是一句口头禅前三张表记录的是Agent做了什么第四张表记录的是我们怎么评判。回归跑完总要给每个会话打个分、算个指标而且这些评判结果也得留档不然下次回归就没有可比性了。字段设计eval_id评测记录ID。session_id被评测的会话。tool_call_id如果这条评测是针对某次具体工具调用的可空。case_group回归集场景名例如“查询订单正常流程”。metric指标名例如“tool_success_rate”“answer_quality”。score数值分数。pass是否通过阈值。judge_model本次评测用的模型或规则例如“local_llama_31”“rule_based”。criteria_version评价标准版本防止标准漂移。created_at评测时间。criteria_version值得多说两句。你可能会调整评测提示词今天要求严格一点明天要求宽松一点如果不记录版本两次分数差距到底来自Agent改动还是评测标准改动谁也说不清。把评测标准本身也纳入版本管理之后这个问题就自动消失了。3.5 建表SQL一抄就能用直接给出SQLite建表语句方便你直接复制。我用了外键约束为了让关系更紧密省得自己写错关联。CREATE TABLE IF NOT EXISTS agent_session ( session_id TEXT PRIMARY KEY, agent_version TEXT NOT NULL, task TEXT, input TEXT, input_hash TEXT, started_at INTEGER, finished_at INTEGER, status TEXT DEFAULT running, tags TEXT, extra TEXT ); CREATE TABLE IF NOT EXISTS message ( msg_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT, tool_calls_json TEXT, created_at INTEGER, FOREIGN KEY (session_id) REFERENCES agent_session(session_id) ); CREATE TABLE IF NOT EXISTS tool_call ( call_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, message_id TEXT, tool_name TEXT NOT NULL, arguments TEXT, result_json TEXT, latency_ms INTEGER, status TEXT, error TEXT, created_at INTEGER, FOREIGN KEY (session_id) REFERENCES agent_session(session_id), FOREIGN KEY (message_id) REFERENCES message(msg_id) ); CREATE TABLE IF NOT EXISTS eval_record ( eval_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, tool_call_id TEXT, case_group TEXT, metric TEXT, score REAL, pass INTEGER, judge_model TEXT, criteria_version TEXT, created_at INTEGER, FOREIGN KEY (session_id) REFERENCES agent_session(session_id) );关于存储选型本地Agent场景直接上SQLite就够了。一个会话几十条消息、几十次工具调用数据量很小一个文件全部搞定不需要起MySQL服务也没有分布式那种复杂度。等你真的跑出问题再考虑迁移到Postgres也不迟四张表的关系模型迁移起来成本很低。4. 离线回归把历史会话当考卷回放代替真跑有了Evidence表之后离线回归水到渠成。它最大的好处是快、便宜、可重复。你不用真的去请求真实API也不用等待外部服务响应跑一次全部历史场景只用几分钟而且可以反复跑跑完结果都在表里。4.1 回归集怎么构建先录制再打标后采样第一步是录制Agent在真实环境下的运行过程。这个不用额外操作只要你在实际使用Agent的时候Trace在记录每次运行都会自动写进三张表。跑一段时间之后你手里就有了一批真实场景下的会话。第二步是给会话打标。利用agent_session的tags字段给每个会话标上场景类别比如“查询订单”“多工具协作”“参数异常输入”“用户取消任务”。这一步很关键因为之后你要按场景采样而不是随机挑。第三步是采样。回归集不要全量搬每个场景挑5到10条即可。原因有两个一是LLM有非确定性同一输入每次输出不完全一样保留太多重复场景只会增加噪音二是回归要快100条以内的案例一个本地模型跑回放也花不了太久。选择采样案例的标准也简单覆盖正常流程、覆盖边界输入、覆盖曾经出过错的场景。一个“曾经出错但现在修好”的案例比十个正常案例都值钱。4.2 回放机制不重跑工具只重放证据离线回归的核心机制是回放。回放时Agent照常跑LLM该生成就生成但工具调用不会真的执行而是从录制好的证据里找出对应结果返回给Agent。这里有一个很关键的技术细节如何找到对应结果。最稳妥的方案是按会话内的时间顺序做“因果回放”。也就是说原始trace里第N次调用某工具回放时如果LLM发起的是相同工具调用就取第N次录制的result_json返回。如果LLM发起了一个原始trace里没有出现过的工具调用说明这次行为发生了偏离此时应该记录为“越界调用”并返回一个模拟错误结果比如“unexpected_tool_call”。模拟错误这一步很重要它不是为了让回放失败而是为了让指标能够暴露问题。你可以在回归报表里看到越界调用的次数和名称一眼就知道改动后的Agent是否多了不该有的行为。下面是一个简化版回放器的核心逻辑。class ReplayRegistry: def __init__(self, store, session_id): self._table {} self._extra [] calls store.get_tool_calls(session_id) for c in calls: self._table.setdefault(c[tool_name], []).append(c) def __call__(self, tool_name, arguments): queue [c for c in self._table.get(tool_name, []) if not c.get(used)] if not queue: self._extra.append(tool_name) return {error: unexpected_tool_call, message: 离线回放时发现原始trace里没有的额外调用} c queue[0] c[used] True return c[result_json]实际接入的时候你在Agent的工具注册表里把真实工具换成这个ReplayRegistry即可。因为函数的签名不变Agent那边没有任何感知它还以为自己在调真实工具其实拿到的全是历史证据。4.3 指标怎么算从Trace到分数回放跑完后指标计算就变得非常直接。下面是我经常用的一组指标初学者可以先从这里开始不用追求大而全。指标计算方式说明工具调用成功率回放中statusok的比例检查链路是否稳定平均/最大耗时latency_ms的均值与最大值发现性能劣化重试率同一工具调用次数超过1次的比例暴露参数或prompt问题越界调用数ReplayRegistry中extra列表长度检测幻觉调用最终答案合格率对最终输出按评分规则打分结果质量最终答案的合格率初级选手可以先用规则评分检查是否包含关键字段、是否触发了不该有的拒绝分支等等。稳定之后再引入LLM评分用一个固定的评分提示词对最终回答打分。评分之前务必记录judge_model和criteria_version。什么时候引入LLM评分我建议至少等你手头有50条以上采样案例后再做不然小样本下评分误差会被放大。4.4 一次回归的命令行体验我自己的回归流程长这样。python replay.py --session-file golden_sessions.json --agent-version v1.3.2-8f3a1replay.py读取采样出来的回归集每一条都跑一遍回放写入eval_record表最后输出一个对比表格同一个案例在v1.3.1和v1.3.2两个版本下的指标差异。这个差异表格就是我做版本发布判断的主要依据。回归不需要跑完整个历史库只跑精心采样的那几十条案例。我坚持的原则是回归集合宁缺毋滥一个“高质量、稳定、覆盖关键路径”的100条案例集比一个“量大、重复、噪音高”的1000条案例集更有价值。5. 初级翻车实录五个最容易搞砸的细节这套方案我用了小半年期间的失败案例比成功案例更有教育意义。归纳出来就是五个反复出现的坑你从一开始就能绕开。5.1 脏数据进库回放全乱第一坑也是最容易踩的入参出参没有经过统一序列化就直接入库。Python的datetime、bytes、Path对象默认都不是JSON类型直接json.dumps会报错。我一开始是逐段打补丁这里转个字符串那里自定义编码结果入库的数据五花八门回放时拿到格式不一的参数LLM都懵了。后来我在store层做了一个统一入口所有数据在写库之前强制过一遍安全序列化函数。缺省fallback规则是有__dict__的转成字典否则转成字符串。别小看这个entry它保证了你库里所有JSON字段都是可解析的。5.2 并发工具调用把时间线搅乱第二坑并发调用导致消息先后顺序不稳定。Agent有时会在一次回复里同时发起多个工具调用比如查天气和查日历同时发出去。如果我用created_at排序两条消息的时间戳可能因为调度原因倒过来回放时就乱了。解决方式是给message表加seq字段用严格递增的计数器维护顺序而不是依赖系统时间。如果Agent主循环是多线程的在分配seq的地方加一个线程锁。规则就一条时间线以Agent决策时的发出顺序为准不以落库时间或系统时间为准。5.3 评测标准漂移对比失真第三坑评测标准没有版本化导致对比失真。我有一段时间为了让指标好看把评分提示词改严格了结果跑出来的分数明显下降。我当时差点以为是Agent退化了后来排查才发现改的是评测标准不是Agent本身。从那以后我把criteria_version当作硬性字段更新评分提示词后必须先用旧版本提示词重新评测历史集得出一个“标准漂移基线”再判断Agent本身的影响。这个教训让我的所有比较都变得可信。5.4 只盯最终答案忽略过程退化第四坑只看最终答案对过程变化视而不见。Agent最终说“好的”但如果它为了达成这个结果调用了额外5次无关工具这不能叫正常这叫过程退化。离线回归的价值有一半藏在trace里。我建议你每次回归至少看一眼“工具调用路径摘要”也就是这个会话依次调用了哪些工具。两个版本如果路径差异巨大哪怕最终分差不多也应该谨慎合并代码因为行为模式已经变了。5.5 回放掩盖了真实API问题第五坑回放太顺手掩盖了外部依赖变化带来的问题。回放拿的是录制好的result_json如果某个工具API字段结构在新版本里变了回放测不出来因为根本没真的调那个API。解决办法是在回归之外保留少量“真跑”冒烟测试比如每个工具选一个最典型的真实输入跑一遍确认参数、鉴权、返回结构都没问题。回放管的是Agent行为真跑管的是工具本身连通性两者缺一不可。6. 跑了一个月之后我的体感这套4张表的方案跑了一个多月后最明显的变化是我改Agent的心态稳了。以前改prompt像开盲盒现在改之前先跑一遍回归集改完照跑一遍同一套考卷差异一目了然。哪怕是在本地环境里跑我也有了一种“有据可依”的踏实感。如果非要说一个这套方案的天花板那就是它只负责把你带到一个相对可观测的起步阶段真正的Agent质量评测远不止这些。比如更细粒度的工具参数合理性评估、多轮对话下的语义一致性分析、用户长期偏好建模都是后面要做的更重的活。但这些东西都有一个前提你得先有一份干净、结构化、可回放的运行证据。4张证据表恰好就是这个地基。所以我的建议是别等到项目变复杂了才想起来补观测现在就把这4张表建上。本地Agent起步阶段存一个SQLite文件成本几乎为零但等你真正需要回答问题的时候会发现所有证据都已经准备好了这种先发优势比临时补数据值钱得多。