☰
用4张SQLite证据表为本地Agent建立可回放可验证的离线回归机制
2026/10/14 9:05:05 网站建设 项目流程

年初的时候我在折腾本地Agent,卡了很久的其实不是模型选型,也不是工具数量,而是“改完代码不敢上线”。今天把Prompt里的system message调整一下,明天给某个工具加个参数,看起来都是小改动,但你根本不知道它会不会把老三样任务的流程带偏。后来我想明白一件事:本地Agent缺的不是更复杂的框架,而是一套可回放、可验证的机制。于是我用最笨的办法——4张SQLite证据表,把Tool Trace和离线回归串起来,意外发现效果出奇地好。这篇文章就把这套方案完整拆开:四张表怎么设计、插桩怎么埋、离线回归怎么跑、以及我实际踩过的坑。都是初级可上手的代码量,不需要LangChain,不需要Agent平台,懂Python基础就够了。

1. 先说结论:这套方案解决的是Agent“不敢改”的问题

本地Agent相比在线Agent,最大的优势是数据不出本机、每次调用可控、成本可预期,但代价是什么呢?没有平台帮你托管轨迹,没有可视化界面看它到底干了什么,出了问题你只能对着终端日志发呆。更麻烦的是,Agent本身是“非确定性的”程序——相同输入,模型可能给出不同决策路径。这导致很多人写完第一版能跑通之后,后面每一步迭代都变得提心吊胆。

我管这套方案叫“给Agent建立工作档案”。工作档案由两个核心组成:

  • Tool Trace(工具调用追踪):把Agent一次完整的任务执行过程,包括它调用了哪些工具、按什么顺序调、传入了什么参数、工具返回了什么内容,全部记录到数据库。
  • 离线回归(离线回放验证):用历史记录好的“档案”作为基准,在改动代码或Prompt之后,重放相同的输入,比对新版Agent的行为和旧版是否一致。

整个方案只要4张证据表就够了,分别承担四个职责:任务登记、工具调用流水、Agent推理轨迹、回归断言结果。

我喜欢把这不叫“日志系统”,因为日志是给人看的,而证据表是给程序用的。日志你可以随意格式化,但证据表有严格的结构,它要能让一段脚本读出来,自动判断“新版Agent的行为是否通过了回归”。这也是我一开始反复纠结的地方:网上很多Agent观测工具,功能一大堆,但落到“我怎么知道这次改动没有破坏以前的功能”这个具体问题上,反而没什么现成的答案。于是我自己动手,用SQLite建表,用不到两百行Python实现插桩和回放,目前已经连续跑了四个月,挺稳的。

这套东西特别适合谁呢?正在本地搭个人Agent、而且会频繁改Prompt和工具定义的开发者。你可能只是一个人维护代码,但有了这套证据表,你的本地Agent就具备了最基本的“可回归性”,改代码的时候心里有底。

2. 四张表怎么设计:一张表扛一个职责

证据表不是凭感觉随便建的,每一张都对应一个必须回答的问题。

  • 这次任务是什么时候跑的、用的什么模型、最终结论是什么?→ 交给task_runs
  • 这次任务中间调用了哪些工具,参数和返回是什么?→ 交给tool_calls
  • Agent的“思考过程”长什么样,为什么决定调这个工具?→ 交给trace_steps
  • 这个任务的回归基准是什么,对比结果怎么样?→ 交给regression_cases

下面具体拆每一张表。

2.1 任务流水表(task_runs):记录一次完整执行的“档案页”

这张表是整个方案的根表,一次任务对应一条记录。字段如下:

字段名类型说明
task_idTEXT PRIMARY KEY每次任务的唯一ID,我习惯用日期_时间_随机数
goalTEXT用户输入的原始目标,用于离线回归时重放
modelTEXT用的模型标识,比如 qwen2.5-7b-instruct
temperatureREAL本次运行的采样温度
start_timeTEXT启动时间
end_timeTEXT结束时间
statusTEXTstarted / finished / failed
final_answerTEXTAgent最终回复的全文
meta_jsonTEXT扩展信息,比如Prompt版本号、工具列表版本号

注意一下我为什么把temperature和model单独设字段。因为后面对比两个任务时,如果模型或温度变了,行为本身就可能变化,那么对比结果就失去意义。把这两个字段留住,你在回归报告里能一眼看出本次差异到底是因为代码改动,还是因为模型换了。

meta_json是我后来加的,有点“脏”字段的意思,但很有用。比如我在本地改了Prompt之后,会在每次跑任务前给Prompt打一个版本号存进去。这样离线回归的时候,我能看到旧任务用的是v32版Prompt,新任务用的是v35版,结合回归结果就很容易定位是哪一版改动导致的偏差。

2.2 工具调用表(tool_calls):Tool Trace的硬核本体

这一张表是整个方案的灵魂,它记录每一次工具调用的完整上下文:

字段名类型说明
idINTEGER PRIMARY KEY AUTOINCREMENT自增主键
task_idTEXT关联 task_runs.task_id
trace_idTEXT一次工具调用的唯一ID,用于并发场景下区分调用链
step_indexINTEGER当前是任务中的第几步调用
tool_nameTEXT工具名称
input_paramsTEXTJSON序列化的入参
output_resultTEXTJSON序列化的工具返回结果
errorTEXT如果有异常,记录异常信息
duration_msINTEGER单次调用耗时
is_cachedINTEGER0表示真实调用,1表示离线回归时从缓存读取
created_atTEXT记录写入时间

在字段设计上我吃过一个亏:一开始没放step_index,只放了created_at。结果多工具并发场景下,时间戳相近的记录排序经常会乱,回放时工具序列怎么都对不上。后来老老实实加了step_index,在Agent主循环里维护一个计数器,每次调用工具前自增,顺序就再也不乱了。

另外,input_params和output_result我都建议存JSON字符串而不是存普通文本。原因是回放和比对时,JSON可以按字段精细对比,“工具A的参数里变了什么”一目了然。如果存纯文本,那你只能做整段相似度,定位不到细粒度差异。

有一点必须提醒:不是所有工具输出都适合全量入库。比如一个工具返回了某个文件的完整全文,几万字,全存进去表会迅速膨胀。我的做法是默认只存前500个字符(按实际长度截断),超过的部分在JSON里额外加一个字段truncated: true。你要做工具结果的全文检索时,再单独按索引去查原始数据,不要让证据表变成“文件备份表”。

2.3 思考轨迹表(trace_steps):记录Agent每一步的“理由”

只看工具调用链,你只能知道Agent做了“什么”,不知道它“为什么”这么做。而离线回归要想判断新版是否“跑偏”,必须看到决策依据。所以第三张表叫trace_steps,我把Agent输出里非工具调用的部分,尤其是思考过程和阶段性结论,按步骤拆开存进去。

字段结构:

字段名类型说明
idINTEGER PRIMARY KEY AUTOINCREMENT自增主键
task_idTEXT关联任务ID
step_indexINTEGER对应工具调用表的步骤序号
step_typeTEXT取值:reasoning / tool_call_plan / tool_result_summary / final
contentTEXT模型输出内容
tokens_usedINTEGER这一步消耗的token数
created_atTEXT记录时间

这个地方容易犯一个认知错误:很多Agent框架本身已经带了历史消息数组,那不是直接就能当trace用吗?其实不行。历史消息数组只解决了“保存了什么”,没有解决“结构化定位什么”。我的回归脚本如果要对比两版Agent在第二步的思考差异,直接从trace_steps表按task_id + step_index取就行,正则都不用写。用历史消息数组,你得自己去解析角色、解析content,麻烦不说,prompt结构一变,解析逻辑就全废了。

trace_steps还有一个用途:出问题的时候回查。比如离线回归发现新版Agent在第三步没有调用预期的搜索工具,我直接看第三步的step_type='reasoning'的内容,很快就能判断这是Prompt没写清楚,还是模型输出格式改了。

2.4 回归断言表(regression_cases):给每个任务定一个“验收标准”

前两张表解决的是“记录”,这张表解决的是“断言”。一次离线回归跑完了,到底算通过还是失败?不能靠人眼去比对两坨文本,要把标准量化,落到表里。

字段名类型说明
idINTEGER PRIMARY KEY AUTOINCREMENT自增主键
task_idTEXT对应基准任务的ID
case_nameTEXT比如“检索本周笔记并总结”
expected_tool_sequenceTEXTJSON数组,期望的工具调用顺序,如 ["search_notes", "read_note", "summarize"]
expected_final_keywordsTEXT最终回答必须包含的关键词,用JSON数组存
min_tool_sequence_matchREAL工具序列的最低匹配率,默认1.0
min_similarityREAL最终回答与基准回答的最低相似度,默认0.7
last_run_statusTEXTpassed / failed / skipped
last_run_diffTEXT最近一次回归的差异摘要
created_atTEXT创建时间

你可能注意到我没有把“期望的最终回答全文”作为断言标准,因为LLM的输出不可能一模一样,非要精确相等纯属自己折磨自己。我的做法是混合断言:

  • 工具序列必须完全一致(除非你明确知道本次改动就是要调整调用序列,这种情况我会在跑回归前先手动更新 denneexpected_tool_sequence)。
  • 最终回答用文本相似度方法,低于阈值判失败。
  • 最终回答里必须包含expected_final_keywords指定的关键词,用来兜底。比如“总结”场景,至少要出现“要点”“结论”这类词。

顺便说一句,这是我个人的第四张表设计。如果你后面做得更野,也可以加更多的断言类型,比如“参数范围约束”“输出不能包含某个URL前缀”,这些都可以统一扩到regression_cases里。但一开始,工具序列 + 关键词 + 相似度这套组合已经足够应付绝大多数本地Agent场景了。

3. Tool Trace插桩:让Agent每走一步都留下脚印

表设计得再好,如果代码里不埋点,也是空中楼阁。这一章讲插桩的落地方式和我踩过的几个细节。

3.1 用装饰器包住所有工具函数

最简单的插桩方法,不是修改Agent循环里每一处调用,而是给所有工具函数套一个统一的装饰器。装饰器负责三件事:记录入参、执行工具、记录出参和耗时。代码如下:

import json import time import traceback import uuid def trace_tool(tool_func): def wrapper(task_id, step_index, trace_id=None, **kwargs): trace_id = trace_id or str(uuid.uuid4()) start = time.time() error_msg = None result = None try: result = tool_func(**kwargs) return result except Exception: error_msg = traceback.format_exc() raise finally: duration_ms = int((time.time() - start) * 1000) save_tool_call( task_id=task_id, trace_id=trace_id, step_index=step_index, tool_name=tool_func.__name__, input_params=safe_serialize(kwargs), output_result=safe_serialize(result), error=error_msg, duration_ms=duration_ms, is_cached=0, ) return wrapper

这段代码里有几个地方特别说一下:

safe_serialize必须写。因为工具入参和返回值未必都能直接被json.dumps处理,比如datetime对象、numpy数组,或者某些自定义类。直接序列化会让整个插桩崩溃,而插桩崩溃是绝对不应该发生的——它不能影响Agent主流程。我实现的safe_serialize大概长这样:

def safe_serialize(obj): try: return json.dumps(obj, ensure_ascii=False, default=str) except TypeError: return json.dumps({"type": str(type(obj).__name__), "repr": repr(obj)}, ensure_ascii=False)

另外,装饰器里我用finally保证即使工具抛出异常也能留下记录。注意在except里我先把 traceback 存进error_msg,然后又raise把异常继续往上抛。这样既不会影响Agent对异常的正常处理,又能让证据表里留痕。我见过一些方案,插桩把异常吞掉了,导致Agent的行为判断出错,那种埋点宁可不做。

如果你觉得装饰器不好统一管理,也可以在第3.2节说的主循环埋点方案里做。但装饰器是我用下来最舒服的,它不侵入Agent核心逻辑,新加一个工具只需要在注册时套上@trace_tool即可。

3.2 在主循环里记录思考过程

工具调用能通过装饰器解决,思考过程怎么办?我最初没有记录 trace_steps,后来回归时发现一个问题:两版Agent调用的工具一模一样,但新版中间多了一句判断“用户可能想按时间筛选”,于是后续行为就变了。只看工具调用表,你猜不到原因。还是得记轨迹。

我自己的做法是在调用LLM获取回复之后、解析工具调用之前,把模型输出的全文按规则拆开:

def record_trace_step(task_id, step_index, step_type, content, tokens_used=0): try: conn = get_db_conn() conn.execute( "INSERT INTO trace_steps (task_id, step_index, step_type, content, tokens_used, created_at) " "VALUES (?, ?, ?, ?, ?, ?)", (task_id, step_index, step_type, content, tokens_used, datetime.now().isoformat()), ) conn.commit() except Exception: # 实录失败不能影响主流程,只打日志 logging.exception("trace step insert failed")

因为Agent主循环每次和LLM交互的内容可能很长(比如ReAct模式下的observation,一长串),我存到 trace_steps 里之前会做一层收紧:把过长的内容截到1000字符,但确保content前缀信息不丢。真正要全文,再回日志文件去捞。这里的原则是:证据表是索引,不是全量仓库。

3.3 敏感信息处理:不能把钥匙留在表里

做本地Agent,工具难免会碰到本地路径、API Tokens、甚至个人隐私数据。直接落库肯定不对。我处理规则很简单:

  • 入参里凡是字段名命中了敏感词白名单(如 token、key、secret、password、authorization),值统一替换成***。
  • 路径类字段,只保留文件名和目录层级数,不保留完整路径。
  • 如果一步工具调用返回的内容里包含明显的大段个人数据(我这边用简单的长度+类型判断),截断存储。

这不是安全洁癖。因为你后面会拿证据表去跑离线回归、去和别人讨论,万一表里的密钥跟着JSON一起发出去,代价没法估。顺手做个脱敏,成本极低,省心极高。

4. 离线回归怎么跑:不真调工具,也能验证行为

这套方案最大的收益,其实是“不花钱、不依赖外部服务”就能反复验证Agent行为。离线回归的核心思路一句话概括:用历史缓存的工具返回结果,替代真实工具执行,重放一次Agent主流程,然后对比新旧行为。

4.1 准备回归基准集

先把task_runs表里已经跑过的任务捞出来,挑几个覆盖典型场景的:一个涉及多步工具调用,一个只有单次简单查询,一个之前出过错但现在已修复的。每个任务对应regression_cases表里的一条记录。

我的做法是专门有几个函数用来创建回归用例。比如:

def build_regression_case(task_id, case_name): seq = query_tool_sequence(task_id) final_ans = query_final_answer(task_id) keywords = extract_keywords(final_ans, top_n=5) insert_into_regression_cases( task_id=task_id, case_name=case_name, expected_tool_sequence=json.dumps(seq), expected_final_keywords=json.dumps(keywords), min_similarity=0.7, )

extract_keywords可以简单按词频抽,也可以直接用jieba这类库。反正目的只是给回归断言加一道保险锁,不要求词库严谨。

4.2 构造“缓存版”工具集合

离线回归时不希望工具真的去访问本地数据库、真的去调搜索引擎。所以我要构造一套覆盖在工具层之上的“缓存读写层”。回到第3.1节的装饰器,我会给工具调用的save_tool_call和实际执行之间插入一个tool_runner:

class ReplayToolRunner: def __init__(self, task_id, cache_cursor): self.task_id = task_id self.cache_cursor = cache_cursor # 一辆游标,用于读历史tool_calls self.step_counter = 0 def run(self, tool_name, **kwargs): self.step_counter += 1 step_index = self.step_counter row = self.cache_cursor.get(tool_name, step_index) if row is None: print(f"[replay] cache miss: tool={tool_name} step={step_index}") raise CacheMissError(tool_name, step_index) # 记录本次是缓存回放 save_replayed_call(self.task_id, tool_name, step_index, kwargs, row["output_result"]) return json.loads(row["output_result"])

为什么要在缓存读取时也落一条记录?因为我想知道这次回归实际走了几步,哪些步骤在缓存里命中了。万一新版Agent在第二步就应该调search_notes,结果它没调、改调了search_web,脚本会报cache miss,我能立刻在报告里看到。

缓存命中的核心是tool_name + step_index对齐。这里有一个隐含前提:预期新版Agent的工具调用顺序和旧版一致。如果顺序本身就不一致,说明行为已经变了,跑回归本来就该失败。所以用tool_name + step_index做key是合理的,它把“顺序一致性”直接内嵌在运行机制里。

4.3 重放主流程并生成对比报告

有了ReplayToolRunner,接下来就是在同一份Agent代码上,用相同goal和这套mock工具,重跑一遍主循环。主循环里的工具调用入口,从原本的call_tool(tool_name, **kwargs)换成runner.run(tool_name, **kwargs)。如果你原来的代码工具调用写得比较规范,这只是一处改动。

跑完之后,脚本要做三步比对:

  1. 工具序列比对:新任务实际的(tool_name, step_index)序列,与expected_tool_sequence做顺序匹配。
  2. 最终回答相似度比对:新版 final_answer 与基准 final_answer 的相似度。
  3. 关键词命中检查:新回答里是否包含expected_final_keywords中的关键词。

我用来算相似度的函数很简单,甚至没上embedding模型,直接用的是difflib,因为本地Agent的最终回答句子通常不会特别长,字符级相似度已经够用了。

def text_similarity(a, b): from difflib import SequenceMatcher return SequenceMatcher(None, a, b).ratio()

如果你要更语义化的比对,也可以后面换成本地embedding模型算余弦相似度。但我的经验是,回归测试首要目标是抓“明显偏差”,语义级细差反而交给人工复核,离线回归的定位是“挡大错”,不是“审美挑剔”。

4.4 判定和报告:一次跑完,谁挂了谁没挂

跑完所有基准用例后,脚本会给出这样一个汇总:

回归用例工具序列相似度关键词结论
检索本周笔记并总结全匹配0.92命中passed
查找PDF摘要第二步多了一个多余调用0.61缺失failed
日程查询全匹配0.88命中passed

看到第二行“第二步多了一个多余调用”,我就能准确锁定是哪个位置、哪个工具出了问题。然后回到trace_steps表看那一步的思考内容,判断是Prompt把Agent带偏了,还是新版工具描述写得有歧义。

这个流程跑下来,一次回归大概几十秒到几分钟,取决于你用LLM重放的次数。因为工具调用全是缓存,不涉及网络和真实执行,速度主要花在模型推理上。对我来说,这已经足够支撑日常迭代节奏了。

5. 我踩过的坑:离线回归中最容易翻车的5个细节

方案听起来很顺,但实际跑起来,坑是一个接一个。下面五个细节,如果你不提前处理,几乎百分之百会翻车。

5.1 模型非确定性:同一个输入,两遍跑出两个结果

LLM不是确定性函数。即便温度设为0,在部分本地部署的推理引擎里,仍然可能因为浮点计算、采样实现差异产生微小波动。如果工具序列因为一次微小波动变得顺序不一样,回归就会报失败,然后你反复重试,时好时坏,极其烦人。

我的应对方式是三层兜底:

  • 第一,temperature固定为0或0.1,这是最基础的对齐。
  • 第二,同一个回归用例,连跑三次,只要至少两次判定通过,就算通过。这样能把偶发抖动的影响摊薄。
  • 第三,对比工具序列时增加一个小容错:允许相邻两步的交换,但需要在报告里标注“swap diff”。也就是说,如果两版工具都是 [A, B, C] 和 [A, C, B],我不直接判失败,而是提示“顺序交换,请人工确认”。

5.2 缓存miss的误导:新版不一定错了,也可能是工具变更了

你改了一个工具函数名,或者给某个工具加了一个新参数之后,历史缓存里tool_name对不上,kwargs也对不上,Replay跑起来一路cache miss,然后报失败。这时候不一定意味着行为恶化,可能只是单纯的工具定义变了。

所以我会在回归脚本里把cache miss单独归类,不直接计入失败。报告里会显示“本次运行有3处缓存未命中,涉及工具 search_notes / summarize”。我拿到这个信息后,先判断是因为有意改动还是意外破坏。如果是改工具名导致的,我会更新regression_cases.expected_tool_sequence,重新建立规范缓存。

5.3 动态数据污染:时间戳、随机ID把相似度拉下来

Agent在真实执行时,很多工具返回会夹杂动态数据。比如“当前时间是2025-06-18 14:30:22”,或者某个数据库查询生成的随机订单号。这些内容在新旧两版里肯定不一样,最终回答相似度因此被拉到0.5以下,回归直接红了。

解决方案在插桩阶段就要做,也就是第3.3节说的脱敏归一化。我在safe_serialize里再加一道正则替换,把 ISO 时间、unix时间戳、数字ID等模式统一替换成占位符,比如把所有\d{4}-\d{2}-\d{2}T[\d:]+换成<TIME>,把6位以上的连续数字串换成<ID>。这样存进证据表的已经是“清洗过”的数据,离线回归的文本相似度就不会被动态字段干扰。

5.4 只记录工具输出,不记录任务上下文,导致回放困难

早期我犯过一个大错:Tool Trace只记录工具入参和出参,没有记录任务初始的完整goal。结果后面做回归时,想重放一个历史任务,却发现我只知道当时调用过哪些工具,完全不知道用户最初提了什么需求。没有goal,重放无从谈起。

这个问题的根源是我当年只设计了工具调用表,没设计task_runs表。后来补上task_runs.goal字段后,才把“可重放性”真正打通。如果你现在刚起步建表,强烈建议第一张表就是task_runs,而且goal字段必须完整,不要让任务ID成为孤儿。

5.5 回归全绿,但线上还是崩:环境漂移问题

最有迷惑性的坑:离线回归在你自己电脑上全绿,部署到另一台机器或者换了Python版本后,Agent行为直接变了。这种环境层面的不一致,不是证据表本身能解决的,但你可以靠证据表把“玄学问题”转化为“可记录问题”。

我的做法是把回归脚本坚持下去,并且在每次跑回归前,让脚本自动输出当前环境的快照:Python版本、依赖包版本、模型文件版本。然后把快照也append到meta_json字段里。以后万一出现“换机器就偏移”,先看两个环境快照差在哪。不要相信记忆,要相信硬数据。

6. 我现在的默认工作流

这四张证据表跑通之后,我现在改Agent的流程变得非常机械且安心:改任何代码或Prompt之前,先把现有基准集跑一遍离线回归,确认全绿;然后改代码;改完再跑一遍离线回归,看哪些case变红。红了的直接看trace_steps定位原因,改完再回归,直到全绿。

这套流程不需要昂贵框架,不需要复杂平台,就是4张SQLite表、一个装饰器、一个replay runner,加起来大概几百行Python。对初级开发者来说,门槛最低的是SQLite,几乎零成本。对老手来说,这套设计反而比很多重观测工具更有掌控感——毕竟每一行数据都是自己定义的,每一条异常都能快速解释。

如果你也在做本地Agent,我建议你从今天开始,先别急着加新功能,把Tool Trace和离线回归这两个基本功做扎实。先录几个典型任务,然后故意改坏一次Prompt,亲眼看到回归脚本是怎么把劣化揪出来的。这个体验比任何工具教程都来得直观。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询