最近在本地跑Agent的时候,我遇到一个特别现实的问题:任务一旦复杂起来,比如一次任务里连续调用七八个工具、中间还要根据前面的结果做多次判断,我根本看不明白Agent到底在想什么。更头疼的是,改动了一版prompt、调整了某个工具的参数之后,我没法判断这个Agent整体到底是变好了还是变坏了。以前我全靠翻聊天记录、看运行日志瞎猜,后来才慢慢想明白一件事:本地Agent要长期可维护,核心不是代码写得有多优雅,而是每一步运行都有“证据”。
这里的“证据”,就是标题里说的4张证据表。这套思路其实非常简单,初级开发者完全能自己落地:把Agent的每次任务、每次工具调用、每个关键状态、每条期望结果,分别落到四张数据库表里。底层用SQLite就够,不需要专门上数据库服务,也不用引入复杂的可观测性平台。配合Tool Trace把调用轨迹完整记录下来,再基于历史轨迹做离线回归,一个轻量版的Agent质量保障体系就成型了。这篇文章我想把完整的做法拆开讲清楚:为什么恰好是这4张表,每张表字段怎么设计,埋点逻辑怎么写,离线回归怎么跑,以及我在实际使用中踩过的那些坑。
1. 先别急着写功能代码:把Agent的运行“证据链”想清楚
1.1 本地Agent最大的痛点:看不见、猜不到、难复现
我最初做本地Agent的方式很原始:写完工具调用逻辑,跑一次任务,看最终输出对不对。对就完事,不对就重新跑一遍再看。这个做法在任务只有一两步的时候没什么问题,但一旦Agent开始自主规划,事情就失控了。
举个例子,一个“搜集信息并整理汇总”的任务,Agent可能会依次调用搜索接口、打开网页、解析正文、提取关键字段、再生成总结。如果最终结果不对,可能是调用顺序错了、可能是某次工具参数传错了、也可能是中间上下文拼接漏了信息。而本地Agent本质上是黑盒,只给我一个最终输出,中间过程全靠日志。更麻烦的是,Agent运行有随机性——同一个输入,两次结果可能不一样。于是排查问题经常变成“死循环”:你改了一个prompt,结果看着像变好了,但下次跑又变回老样子。你根本分不清是自己的改动生效了,还是纯粹碰运气。
所以后来我给自己立了一条规矩:任何Agent任务,运行前先想好“如果这次跑坏了,我有没有办法还原现场”。还原现场靠什么?就靠运行过程中留下的结构化记录。这种记录我称之为证据表,它应该包含任务发生了什么、按什么顺序发生、在什么状态下发生、以及我们期望发生什么。四条信息,刚好对应四张表。
1.2 为什么是4张表,而不是一个大JSON文件
你可能觉得,用loguru或JSONL写个日志文件不也能记录这些东西吗?早期我确实那么做过,把所有内容揉进一个大JSON文件。结果很快就发现几个问题:第一,日志文件一旦多起来,定位某次任务得靠手动翻路径;第二,我想统计“哪些工具调用耗时最长”“哪次任务token消耗最离谱”这类问题,写日志文件几乎没法高效完成;第三,并发跑多个任务时,往一个文件里写内容,还会出现内容穿插、无法对上的情况。
于是我把方案换成SQLite。理由很直接:单机、零部署、一个文件就够,天然支持SQL查询、事务和并发,初级开发者也很容易上手。给它一张表,就能解决90%的查询需求。引入4张表也不是凭空想出来的,而是因为“描述Agent发生过什么”和“描述Agent应该发生什么”本来就是两类完全不同的信息。放在一起,可以方便地做关联分析和回归对比。
打个比方,这就像飞机的飞行记录仪,不会只记录“飞了多久”这一个参数,而是把高度、速度、引擎状态、操作指令等关键仪表全部存下来。出问题时,所有关键环节都能回溯。Agent的证据表也是这个思路:任务主表是总档案,工具调用轨迹是黑匣子,状态快照是决策环境的“照片”,期望表是判断“表现正常”的报警阈值。
2. 四张证据表的建表语句与使用逻辑
2.1 agent_task:给每次任务运行一个“主档案”
第一张表,我命名为agent_task,用来记录一次任务的整体生命周期。你可以把它理解成订单表:每一个Task_ID对应一次完整的Agent运行。之所以要先建这张表,是因为后续track、工具调用、状态快照、期望对比,都需要挂在同一个任务ID下面,否则多条记录根本串不起来。
我的建表语句大致如下:
CREATE TABLE IF NOT EXISTS agent_task ( task_id TEXT PRIMARY KEY, task_name TEXT NOT NULL, task_desc TEXT, status TEXT DEFAULT 'running', model_name TEXT, temperature REAL DEFAULT 0.0, seed INTEGER, input_digest TEXT, output_digest TEXT, started_at INTEGER, finished_at INTEGER, total_tokens INTEGER DEFAULT 0 );重点说说几个容易忽略的字段。一个是model_name和temperature,很多人建表时觉得没必要,后来做离线回归才发现关键:如果模型版本变了、temperature不是0,那两次任务的差异根本分不清是代码改动导致的还是模型随机性导致的。另一个是input_digest和output_digest,这里我存的不是完整输入输出,而是输入内容的前几十个字符或一段摘要。为什么不存全文?因为任务输入可能非常大,存全文会让表迅速膨胀,而摘要足够用来快速定位“这是哪一批任务”。
status字段建议用running、success、failed、timeout这几个固定枚举。我刚开始没做枚举约束,结果后来发现表里出现了failed!!、Failed这种五花八门的写法,统计时还得先清洗数据。建议在建表时就用CHECK (status IN ('running','success','failed','timeout'))把状态值固定住。
2.2 tool_trace:用一行一行记录,还原每一次工具调用
第二张表是整个方案的灵魂,也是Tool Trace的核心载体。我命名为tool_trace,按时间顺序记录Agent在运行过程中每一步发生的动作。这其实不仅包含工具调用,也包含模型“思考”和“最终回答”,这样可以完整还原Agent的整个决策链。
具体表结构如下:
CREATE TABLE IF NOT EXISTS tool_trace ( trace_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, step_no INTEGER NOT NULL, trace_type TEXT NOT NULL, tool_name TEXT, tool_args TEXT, tool_result TEXT, status TEXT, latency_ms INTEGER, token_used INTEGER, extra TEXT, ts INTEGER );这里trace_type我定义了四种取值:thought(LLM的中间思考)、tool_call(发起工具调用)、tool_result(工具返回结果)、final_answer(最终回答)。你可能好奇,为什么要连“思考”也记下来。原因很简单:排查Agent问题时,最常见的疑问就是“它为什么调用这个工具”。如果不记录模型当时的思考内容,你只能从工具调用顺序反过来猜,非常费劲。
实践时,每条记录都应该尽量存下tool_args和tool_result,这是还原现场最核心的数据。同时记录的latency_ms和token_used一开始可能觉得只是锦上添花,但后来你会发现,这两个字段是做成本分析和性能优化的基础数据来源。
还有一点要注意:step_no不要依赖trace_id自增去判断顺序。因为SQLite的AUTOINCREMENT只能保证生成顺序,不能保证逻辑上的步骤顺序,尤其在异步、并发的场景下,写库先后和真实调用顺序可能不一致。我的做法是在应用层手动维护step_no,每次往同一个task_id里追加时做自增,后文会详细讲。
2.3 state_snapshot:在关键节点把上下文“拍下来”
第三张表,我命名为state_snapshot,用来在Agent运行的某些关键节点,把当时的上下文状态保存下来。为什么要这么做?因为LLM的推理过程依赖整个上下文窗口,问题排查时最缺的恰恰是“当时模型到底看到了什么”。你在事后拿到一个tool_result,但Agent在调用下一个工具之前,脑子里的上下文已经是“原始问题+前序工具结果+中间思考”拼接后的内容。想知道它基于什么做了下一个决定,就必须有快照。
我存储两种快照:输入快照和决策快照。输入快照在任务开始时存一次,记录初始任务描述和初始上下文摘要;决策快照在每次thought之后、调用工具之前存一次,记录“模型在这一步认为应该做什么”。这样如果后来Agent的选择明显偏离常识,你能知道是在哪一步开始跑偏的。
表结构同样沿用轻量级设计:
CREATE TABLE IF NOT EXISTS state_snapshot ( snap_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, step_no INTEGER NOT NULL, snap_type TEXT NOT NULL, ctx_summary TEXT, ctx_hash TEXT, decision TEXT, extra TEXT, ts INTEGER );快照的ctx_summary不推荐保存完整上下文,一个是体积太大,另一个是很多内容其实无关紧要。我会做一个截断处理:只保留最近N条对话摘要和关键工具结果摘要。ctx_hash则是对上下文内容做的哈希,作用是快速判断两个时刻的上下文是不是相同,离线回归时可以拿来做等价对比。一开始我以为这个字段不重要,后来发现它能帮我快速发现“prompt更新后,Agent看到的上下文结构变没变”这一类隐蔽问题。
2.4 expect_case:把“表现不错”固化成可断言的回归用例
前三张表回答的是“发生了什么”,第四张表回答的是“应该发生什么”。只有同时具备这两类信息,离线回归才有真正的依据。这张表我叫expect_case,本质上是把历史任务中表现良好的轨迹固化成一条条可自动断言的规则。
建表语句如下:
CREATE TABLE IF NOT EXISTS expect_case ( case_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, task_name TEXT NOT NULL, expect_tool_seq TEXT, expect_tool_count INTEGER, expect_status TEXT, expect_output_regex TEXT, expect_token_max INTEGER, active INTEGER DEFAULT 1 );这里expect_tool_seq是一个JSON数组字符串,比如任务“整理某公司信息”期望的工具调用序列是["web_search","web_fetch","text_extract","final_answer"]。expect_tool_count约束工具总调用次数,防止Agent陷入循环。expect_output_regex用于对最终输出做宽松匹配,不能要求一模一样,能匹配关键字段就行。
这张表的价值在于:当你修改了Agent的prompt或工具逻辑之后,可以把历史表现良好的任务重跑一遍,然后自动检查是否还满足这些期望。满足,说明这次改动至少没有破坏已知的“良好行为”;不满足,说明有了行为漂移,需要重点审查。它不是万能的,但作为初级方案,性价比非常高。
3. 从埋点到离线回归:一次完整的落地流程
3.1 搭建轻量存储层:一个Python文件解决
因为整个方案不引入外部数据库服务,我用一个简单的Python模块负责初始化数据库和提供连接。SQLite本身是Python标准库的一部分,直接import sqlite3就能用。为了支持并发写入,我会在初始化时显式开启WAL模式,这也是我在踩过几次“database is locked”之后学到的经验。
import sqlite3 from pathlib import Path DB_PATH = Path("agent_evidence.db") def get_conn(): conn = sqlite3.connect(DB_PATH, timeout=15) conn.execute("PRAGMA journal_mode=WAL;") conn.execute("PRAGMA foreign_keys=ON;") return conn def init_db(): with get_conn() as conn: conn.executescript(""" CREATE TABLE IF NOT EXISTS agent_task (...); CREATE TABLE IF NOT EXISTS tool_trace (...); CREATE TABLE IF NOT EXISTS state_snapshot (...); CREATE TABLE IF NOT EXISTS expect_case (...); """)这里有个重要细节:不要在每个Agent任务里反复connect和close,这会引入大量不必要的IO开销。我的做法是每个任务用一个独立连接,在任务生命周期内复用,任务结束再关闭。如果你用ThreadPoolExecutor并发跑多个任务,每个线程持有自己的连接就行。SQLite对并发写有一点限制,但WAL模式下读操作不会阻塞写操作,完全够本地调试用。
3.2 埋点三件套:不侵入Agent主逻辑的小技巧
刚做埋点时我犯过一个错:直接在Agent主循环里到处插入insert into tool_trace ...的代码。结果代码里塞满了数据库操作,可读性极差,中途想改逻辑都得小心翼翼的。后来我总结出三个更优雅的姿势。
第一个姿势是把工具调用统一包装成装饰器。本地Agent通常会把各类能力封装成函数,这些函数就是天然的埋点边界。我写了一个装饰器:
import functools import time import json def trace_tool(tool_name_key): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.time() step_no = next_step_no(task_id_ctx.get()) try: result = func(*args, **kwargs) status = "success" except Exception as e: result = f"ERROR: {e}" status = "error" latency_ms = int((time.time() - start) * 1000) save_tool_trace( task_id=task_id_ctx.get(), step_no=step_no, trace_type="tool_call", tool_name=tool_name_key, tool_args=truncate(str(kwargs), 1000), tool_result=truncate(str(result), 2000), status=status, latency_ms=latency_ms, ) return result return wrapper return decorator第二个姿势是统一记录thought。ReAct循环里模型在调用工具之前总会输出一段思考,我会在调用模型拿到返回后立刻把它写入tool_trace,trace_type设为thought。这样能在时间线上还原“先想了一下,然后决定调用XX工具”。
第三个姿势是做好序列化容错。工具参数和返回值不一定都是JSON可序列化的,比如pandas DataFrame、PIL Image、自定义对象。我统一封装了一个to_safe_str函数,优先JSON序列化,失败就转成repr(),再不行就只保留类型名。这样做的好处是保证埋点代码自身永不抛异常——因为埋点异常会导致Agent业务逻辑跟着崩溃,这是不能接受的。
3.3 离线回归脚本:固定任务池、对比历史轨迹、执行断言
证据表建好、数据能持续写入之后,就可以做离线回归了。我的回归脚本逻辑非常简单,核心就三步:取出一堆历史任务样本、重新跑一遍Agent、把新产生的轨迹与期望做对比。
具体实现参考下面这段伪代码级的Python脚本:
def run_regression(case_list): results = [] for case in case_list: case_id = case["case_id"] task_id = case["task_id"] # 1. 执行一次新的Agent任务,内部会自动写入新的tool_trace new_task_id = run_agent_once(case["input"]) # 2. 读取新轨迹 new_trace = load_trace_by_task(new_task_id) # 3. 读取该case的历史期望 expect = load_expect_by_case(case_id) # 4. 对比工具调用序列 new_seq = [t["tool_name"] for t in new_trace if t["trace_type"] == "tool_call"] seq_match = (new_seq == expect["expect_tool_seq"]) # 5. 断言最终状态和输出 status_match = (new_trace[-1]["status"] == expect["expect_status"]) result = judge_regression_case(seq_match, status_match, new_trace) results.append(result) return results这里有三个关键点要说清楚。
第一,离线回归不是“严格回放”。很多刚接触这个概念的朋友以为,离线回归就是固定文件把之前的输出回放一遍。其实对于带LLM的Agent来说,真正做严格要求的是“录播模式”,需要在工具层做mock,难度比较大。初级方案建议先做“重跑对比”:固定输入,重跑一遍Agent,然后跟历史轨迹和期望做对比。这种做法虽然覆盖不了所有随机性,但已经能暴露大部分行为漂移。
第二,回归前必须固定模型参数。我在agent_task表里记录temperature和seed,就是为了在回归时做到可控。对比新老轨迹前,先确认两次运行使用的模型和参数一致,否则稍有一点差异都会干扰判断。
第三,断言要有层次。工具调用序列是否一致是强断言,应当完全匹配(毕竟同一任务、同一输入,正确的工具规划路径不应该忽左忽右)。最终输出则用正则匹配做弱断言,因为生成式模型的输出天然有波动。
3.4 回归报告:一眼看出Agent哪一步变了
脚本跑完后,我会生成一份简单的文本式回归报告。控制台输出类似下面这样:
Case: 整理XX公司信息 Baseline: web_search -> web_fetch -> text_extract -> final_answer New: web_search -> web_fetch -> web_search -> text_extract -> final_answer Status: FAIL Reason: unexpected extra tool call at step 3 Tokens: 18230 -> 23140 (+4910) Latency: 4.2s -> 6.8s (+2.6s)这份报告能快速告诉我两件事:一是这次改动是否破坏了已知的良好行为,二是如果破坏了,破坏发生在第几步。比如上面这个案例,Agent在抓取页面后又搜了一次,通常意味着第一次搜索结果不完整,它心里没底。看到这种变化,我就会去检查是不是prompt里对“信息是否完整”的判断标准被改松了,或者某个工具返回结果的摘要被截断得太短,导致Agent每次都不放心。
回归报告不一定需要做得多炫,能用表格或者文本把“新旧对比差异”村托出来就足够了。我见过很多团队一开始就想做可视化Web看板,结果光搭报表平台就花了大量时间,反而没时间真正去分析Agent行为。本地Agent发展阶段,控制台输出加一个Markdown表格,完全够用。
4. 实际踩坑记录:这些问题,新手十有八九会遇到
4.1 异步并发下trace乱序,回放对不上
我第一次跑多个任务并发时就栽了跟头:日志表里记录的工具调用顺序和实际发生的顺序对不上,回放时看起来像是Agent先调用了工具A,又回头调用了工具B,实际上不是这样。
问题根源在于,多个任务共用一个SQLite连接,写入顺序受线程调度影响,AUTOINCREMENT的自增ID只反映“谁先到达数据库”,不反映“谁先被业务逻辑执行”。解决办法是在应用层维护每个任务独立的step_no。我用了线程局部变量或协程局部变量,记录当前task_id,每次写trace前先step_no += 1。这样即使并发写入,每个任务内部的步骤顺序依然是严格递增的。
另外,SQLite默认是“同一时刻只有一个写入者”,并发高一点就容易抛database is locked。如果遇到这个问题,可以开启WAL模式、加大连接超时时间。如果还不够,就把并发数控制在个位数,毕竟本地Agent场景本来就不追求极高的吞吐量。
4.2 快照表暴涨,Agent变慢
刚开始我特别相信“状态快照越多越好”,每个工具调用前后都存一次完整上下文。结果跑了半天,数据库文件膨胀到好几个GB,写入耗时明显增加,Agent任务整体响应时间从4秒涨到了8秒。
后来我做了几个优化。第一,只保存关键节点的快照:任务开始、每个thought之后、每次工具调用前、最终输出前;第二,ctx_summary里只保存上下文的“压缩摘要”,不再存完整拼接文本;第三,增加快照数量上限,比如每个任务最多20条,超过就覆盖最早的较不重要的快照。优化之后,数据库体积缩回到200MB左右,性能影响几乎可以忽略。
说句实话,快照这东西是典型的“事后看起来很值得、当时保存觉得很贵”。我的建议是:先用“摘要+关键节点”低成本版本,真到了某个复杂问题需要全量上下文时,再针对那个任务单独开启完整快照开关。
4.3 大字段把SQLite撑爆,查询卡死
工具返回值千奇百怪,尤其有些工具会返回图片的Base64字符串或整篇网页源码。把这种内容直接写进tool_result,单条记录可能就几MB。表里数据一多,任何查询都变得奇慢无比,甚至SQLite文件本身能膨胀到几个GB。
我的解决策略很简单:大对象别进表。超过阈值(比如10KB)的内容,先把内容写到media/目录下的文件里,数据库里只存文件路径和内容摘要。查询时如果需要完整内容,再按路径去读文件。这样tool_trace表始终保持苗条,性能也更稳定。顺带说一句,SQLite并没有网上说的那么脆弱,但如果你在表里塞了一堆大文本,任何数据库都会性能下降,这跟选什么存储无关。
4.4 离线回归结果波动,没法判断改没改坏
离线回归最让人崩溃的一刻,是同一个回归用例连续跑了几次,结果有时候通过、有时候失败。刚开始我以为回归逻辑写错了,后来才确认是LLM自身的采样随机性在捣乱。
要缓解这个问题,我做了两个调整。第一个调整是回归模式下强制使用确定性解码参数:temperature=0、固定seed、可选的greedy策略。不少本地模型支持重复次数惩罚和固定随机种子,这些参数在回归时都要显式设置,避免默认值不同导致结论失真。第二个调整是对每个Case跑多次(比如3次),以多数结果为准。工具调用序列这个指标比较硬,三次里至少两次一致才能判定“稳定”;最终输出这种自然语言指标,则尽量用关键词匹配或向量相似度判断,不做逐字对比。
如果你发现即使temperature=0仍然有波动,那一般是模型后端的beam search或top-k采样没有完全关闭,建议先去确认推理服务参数是否真的生效。
4.5 历史任务“幽灵失败”,调了半天发现是脏数据
有一次我做个回归对比,发现历史任务里某个工具调用状态是error,再一看错误信息,格式跟现在完全不一样。我以为是Agent的代码改动引入了新错误,排查了小半天,最后发现那是很早以前工具返回的结构还没统一,当时存储的原始错误内容在字段里存的是老格式的字符串,后面工具的解析逻辑升级了,但历史trace表里的数据依然是老的格式。
这类“幽灵失败”很容易浪费时间。解决办法是我在tool_trace和state_snapshot表里都加了extra字段,里面放一个schema_version标记。写入时记录当前的序列化格式版本。回归脚本在断言前先做normalize,根据版本号自动适配解析方式。如果遇到旧版本数据,宁可跳过也不要强行解析报错。定期清理不用的历史数据或专门写数据迁移脚本,也能避免脏数据越积越多。
5. 四张表之外的三个进阶玩法
这套4张表方案跑通之后,后面还能顺手做几件很实用的事,成本都很低。
第一个是把历史出错任务自动收成回归用例。我在agent_task表里遇到status='failed'的任务时,会让人工快速看一眼是不是值得保留的失败样例。如果值得,就把它对应的输入、期望行为插入到expect_case表。这样每一轮回归都在自动扩充测试集,等于是把Agent的专业知识沉淀到数据库里。
第二个是成本与性能归因。tool_trace里有latency_ms和token_used,我用一个简单的GROUP BY,就能查出哪些工具最慢、哪些任务消耗token最多。这些数据对本地Agent特别重要,因为本地模型推理速度本来就不如云端API,找到耗时的瓶颈工具,往往比优化prompt更提效。
第三个是稳定性评估。拿同一批任务反复跑,对比每次tool_trace的工具序列相似度,就能看出这个Agent是稳定还是“神经质”。如果工具序列的编辑距离波动很大,说明Agent的规划能力不够稳,可能需要对工具描述或提示词做收敛。这个分析用state_snapshot的上下文哈希也能辅助判断:如果上下文变化很小但决策差异很大,问题多半出在模型本身而不是上下文丢失。
最后分享一点个人体会。整套方案的核心,其实是把开发心态从“只要Agent最终输出对就行”转成“只要Agent的关键过程可复现、可对比,质量就有保障”。我在实际使用中发现,最容易被低估的是state_snapshot表,因为前期数据少的时候,你根本看不到它的价值,可一旦遇到那种“看似输出正确但行为明显奇怪”的隐蔽问题,快照几乎是你唯一能还原现场的线索。另一条经验是不要一上来追求复杂的回放框架,先用这4张表把过程数据攒起来,等样本量上来了,你自然会知道下一步需要什么。毕竟没有证据的Agent,跑得再快心里也没底。