初级也能做的本地Agent:从Tool Trace到离线回归,只需4张证据表
1. 为什么初级开发者也要做本地Agent测试
1.1 Agent不是写出来就完事的
很多朋友第一次接触Agent开发,都是从“调用一个大模型,给它几个工具函数,让它自己决定下一步干什么”开始的。这个阶段特别容易有一种错觉:只要模型够强,Agent就能自己把活干完。
但真把Agent放到真实任务里跑几轮,你会发现它是一个极其“不稳定”的系统。同样一个用户问题,今天回答对了,明天换个提示词、更新个模型版本,结果就跑偏了;同一个问题给它两个工具,它可能选了效率更低的那个;甚至在连续多步调用中,中间某一步工具返回异常,它就开始胡编乱造,把错误的结果当作下一步的输入继续往下推。
这种不稳定不是“改改提示词”就能解决的。它本质上是Agent的决策过程缺乏可观测性——你知道它最终给了你一个答案,但你不知道这个答案是怎么来的。而初级开发者又往往没有搭一整套分布式追踪平台的能力和资源,怎么办?
我的建议是:别一上来就搞那些厚重的可观测系统,先把“本地证据链”建起来。所谓证据链,就是我标题里说的Tool Trace。它的核心思路很简单:Agent每调用一次工具、每产生一条推理、每输出一个结果,我都把它原封不动地记下来。有了这些记录,你才能在报错时回溯现场,也才能在后续改动时做离线回归,确认新改动没有把旧能力搞坏。
1.2 Tool Trace:给Agent的每一步留证据
Tool Trace的字面意思是工具调用轨迹,通俗点说就是Agent在完成一次任务的过程中,先后调用了哪些工具、传入了什么参数、拿到了什么返回值、用了多长时间、哪个环节失败或超时。这一点非常重要,因为Agent和普通接口最大的不同在于,它没有固定执行路径。
普通接口是顺序代码,第一行执行完才能执行第二行,出了问题看报错栈基本就能定位。Agent不一样,它是动态规划路径:模型会根据当前上下文自己决定“要不要调用工具”“调用哪个工具”“参数怎么填”“下一步怎么办”。这个路径是没法通过阅读代码直接推断出来的,只能靠事后回放。
打个比方,传统程序像你按照固定菜谱做菜,第几步放盐、第几步翻炒,都是定死的,糊了你能知道是哪一步操作不对。Agent则像一个自由发挥的厨师,它有自己的想法,今天可能先焯水再炒,明天可能直接下锅。你想搞清楚它为什么把菜做咸了,就必须记录它每一步的完整操作。Tool Trace就是那本操作记录。
对于初级开发者,我不建议一上来就接OpenTelemetry、Jaeger这些重量级链路追踪工具。虽然它们很专业,但配置复杂,概念也多,容易让人失去耐心。我更推荐先用本地文件或轻量级数据库把追踪记录落下来,把“有证据”这件事先做扎实,等后面项目复杂度上来,再迁移到专业系统也不迟。
2. 四张证据表的核心设计
2.1 表1:task_scene(任务场景表)
第一张表是任务场景表,用来记录“这一次任务是什么”。它解决的是离线回归时最基本的归类问题:我要测试哪些场景?每个场景跑了几次?每次跑的时候Agent处于什么配置状态?
这张表的字段建议包含:场景ID、场景名称、任务描述、初始输入内容、Agent配置版本、模型名称与版本、工具列表版本、开始时间、结束时间、状态、备注。
你可能会觉得“场景ID”“Agent配置版本”这些字段是不是有点浪费?但实际维护过回归测试的人都知道,没有版本信息的证据表基本等于没证据。就拿模型版本来说,同一个Agent应用,底层的模型从GPT-4切到某个开源模型,行为变化可能是天翻地覆的。如果你记录里没有模型版本,回归失败时你连“是不是模型变了导致行为差异”这个最基础的判断都做不了。
另外,场景描述这一栏要尽量写清楚任务的初始上下文,而不是只写“故障排查”这种模糊描述。比如“用户报告服务器CPU持续100%,要求Agent登录服务器查看进程并定位异常进程”。这样在回放时,你才能知道当时的输入到底是什么,避免“场景对不上”的尴尬。
2.2 表2:tool_trace(工具调用轨迹表)
第二张表是整个证据链的核心,也就是我之前说的Tool Trace表。它记录的是单次Agent运行过程中的每一次工具调用事件。之所以单独拆出来一张表,是因为一个任务场景下会有多次工具调用,而且每次调用的字段很多,如果混在场景表里,查询和统计都会变得非常困难。
tool_trace表建议字段如下:traceID、场景ID、步骤序号、工具名称、调用参数(JSON格式)、工具返回值(JSON格式)、开始时间、结束时间、耗时、状态、错误信息、模型当时的推理摘要。
这里面最容易被新手忽略的是“步骤序号”和“模型当时的推理摘要”。步骤序号很好理解,就是Agent第几步调用了这个工具,用来还原执行顺序。推理摘要则是我特别想强调的:Agent在调用某个工具之前,通常会生成一段解释性文字,比如“用户需要查询订单状态,所以我先调用订单查询接口”。这段文字在最终回复里可能不会完整展示,但它恰恰是判断Agent决策是否合理的关键证据。
举个真实例子,我之前排查过一个Agent多轮对话串题的问题。用户在对话里先问A订单,再问B订单,Agent居然在第二轮用A订单的ID去查B订单的物流状态。如果只看最终回复根本看不出来问题,但一看tool_trace表里工具调用的参数,就清清楚楚看到它把旧ID传进去了。这就是证据表的价值。
调用参数和返回值建议统一存成JSON字符串,不要拆成几十个列。原因是工具函数的入参千差万别,有的接收ID,有的接收日期范围,有的接收对象,强行拆列会让表结构变得极其僵硬。JSON存储虽然牺牲了一点点查询便利性,但换来的是极强的扩展性,非常划算。
2.3 表3:step_reasoning(中间推理表)
第三张表是中间推理表,记录的是Agent在决策过程里“想了什么”。可能有人会觉得,这不就是把日志里的模型输出搬过来吗?有必要单独建表吗?
太有必要了。当前主流Agent框架大多采用ReAct范式,即“推理-行动-观察”循环。模型在每轮行动之前都会输出一段thought(推理),解释自己为什么决定调用这个工具、下一步计划是什么。这些中间推理内容转瞬即逝,如果不用独立表结构固化下来,排查问题时你只能重新跑一遍Agent,既费钱又费时间,而且大概率无法复现当时的状态,因为外部环境可能已经变了。
step_reasoning表字段建议:traceID、场景ID、步骤序号、推理类型(如thought、plan、observation)、推理内容、关联工具调用ID、时间戳、模型名称。
这里要特别说明一下“关联工具调用ID”字段。它的作用是把推理步骤和工具调用步骤关联起来,形成一条完整的因果链。比如推理表里有一条“我决定调用weather_api查询天气”,它关联的tool_trace记录里就应该是weather_api这条调用。有了这种关联,你才能回答“Agent是先想再动,还是想了没动,还是动了没想”这类判断决策质量的问题。
实际操作中,这个字段可以从Agent框架的回调事件里拿到。很多Agent框架在Instrumentation或Callback机制里会提供on_thought和on_tool_call这类事件,你在处理事件时把当前traceID和步骤序号带上,写入数据库即可。如果框架没有现成回调,也可以在每个工具调用节点前后手动插桩。
2.4 表4:result_regression(结果回归表)
第四张表是结果回归表,它和前面的运行轨迹表不同,它专门记录“我们对这次运行结果的评价”。这一步是把Agent离线测试从“只能看日志”升级为“能自动判定”的关键。
result_regression表字段建议:回归ID、场景ID、traceID、预期行为描述、判定结果(通过/失败/异常/待人工确认)、失败原因类型、证据摘要、判定依据、执行人/执行脚本、回归日期。
什么是预期行为描述?比如场景是“查询天气”,预期行为可以是“Agent应调用天气查询工具,并返回包含城市名和气温的最终回复”。这是一条具体的、可验证的预期。回归时,脚本会把实际结果和预期行为比对,如果没调用工具或者回复里缺了城市名,就判定为失败。
可能有人会问,为什么不能直接把预期结果写在场景表里?因为同一个场景在不同版本、不同数据下可能有多轮回归,每轮有每轮的判定结论。单独建回归表,既能查场景的所有历史回归记录,也能查某一次trace对应的回归结论。这和场景表是一对多的关系,设计上更规范。
这四张表组合起来,就构成了一个完整的证据闭环:任务场景表告诉你“做了什么”,tool_trace告诉你“动用了什么”,step_reasoning告诉你“为什么这么动”,result_regression告诉你“做得对不对”。围绕这4张本地证据表,你完全不需要依赖任何云端追踪服务,就能对本地Agent做透明化调试和离线回归。
3. 搭建本地Agent测试环境与采集工具
3.1 环境准备与依赖
在开始写采集代码之前,先把本地环境准备好。我一般习惯用Python搭建这类测试框架,因为Agent生态里Python相关的库最丰富。你需要准备:
- Python 3.10及以上环境
- SQLite或者DuckDB(二选一,我建议新手先用SQLite,因为它零配置、单文件、随手可用)
- Agent框架本身(比如LangChain、LlamaIndex,或者你自己封装简单的LLM调用+工具注册机制)
- 一个标准的JSON序列化工具(Python内置json就够了)
SQLite是我很推荐的新手友好选择。它不需要单独起一个数据库服务,数据落在本地单个文件里,天然适合“本地证据表”这个场景。测试完可以把整个db文件打包归档,作为一次回归测试的完整证据包。对于并发量不大的调试环境,SQLite的读写性能完全够用。
我见过一些初级开发者一上来就上MySQL或PostgreSQL,理由是“以后生产环境要用”。但本地开发和测试阶段,最忌过度设计。数据库连接、账户权限、网络配置这些都会分散你的注意力。先用SQLite把证据链逻辑跑通,后面迁移也不费劲,改改连接层就行。
3.2 给Agent代码埋点
环境准备好后,下一步是给Agent代码做埋点。我以一次简单的Agent调用为例来说明。假设你的Agent调用流程大概是:接收用户输入 -> 模型决定是否调用工具 -> 调用工具 -> 返回结果 -> 继续推理直到生成最终回复。
埋点的位置就在这每一步之间。最简单的做法是封装一层TraceManager类,提供start_scene、log_tool_call、log_reasoning、log_result四个方法,分别对应四张证据表的写入操作。下面我给一个极简的代码示例:
import json import sqlite3 import uuid from datetime import datetime class TraceManager: def __init__(self, db_path: str): self.conn = sqlite3.connect(db_path) self.trace_id = None self.scene_id = None self._init_tables() def _init_tables(self): cur = self.conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS task_scene ( scene_id TEXT PRIMARY KEY, scene_name TEXT, task_desc TEXT, init_input TEXT, agent_version TEXT, model_name TEXT, start_time TEXT ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS tool_trace ( trace_id TEXT, scene_id TEXT, step_no INTEGER, tool_name TEXT, args_json TEXT, result_json TEXT, start_time TEXT, end_time TEXT, cost_ms INTEGER, status TEXT, err_msg TEXT, reasoning_summary TEXT ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS step_reasoning ( trace_id TEXT, scene_id TEXT, step_no INTEGER, reasoning_type TEXT, content TEXT, related_tool_call INTEGER, ts TEXT, model_name TEXT ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS result_regression ( regression_id TEXT PRIMARY KEY, scene_id TEXT, trace_id TEXT, expected_desc TEXT, judge_result TEXT, fail_type TEXT, evidence_summary TEXT, judge_basis TEXT, reg_date TEXT ) """) self.conn.commit() def start_scene(self, scene_name: str, task_desc: str, init_input: str, agent_version: str, model_name: str): self.scene_id = uuid.uuid4().hex[:8] self.trace_id = uuid.uuid4().hex[:8] self.conn.execute( "INSERT INTO task_scene VALUES (?, ?, ?, ?, ?, ?, ?)", (self.scene_id, scene_name, task_desc, init_input, agent_version, model_name, datetime.now().isoformat()) ) self.conn.commit() return self.scene_id, self.trace_id def log_tool_call(self, step_no, tool_name, args_dict, result_dict, cost_ms, status="ok", err_msg="", reasoning_summary=""): self.conn.execute( "INSERT INTO tool_trace VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (self.trace_id, self.scene_id, step_no, tool_name, json.dumps(args_dict, ensure_ascii=False), json.dumps(result_dict, ensure_ascii=False), datetime.now().isoformat(), datetime.now().isoformat(), cost_ms, status, err_msg, reasoning_summary) ) self.conn.commit() def log_reasoning(self, step_no, reasoning_type, content, related_tool_call): self.conn.execute( "INSERT INTO step_reasoning VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (self.trace_id, self.scene_id, step_no, reasoning_type, content, related_tool_call, datetime.now().isoformat(), "gpt-4o") ) self.conn.commit() def log_result(self, expected_desc, judge_result, fail_type, evidence_summary, judge_basis): regression_id = uuid.uuid4().hex[:8] self.conn.execute( "INSERT INTO result_regression VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", (regression_id, self.scene_id, self.trace_id, expected_desc, judge_result, fail_type, evidence_summary, judge_basis, datetime.now().strftime("%Y-%m-%d")) ) self.conn.commit()这段代码的逻辑并不复杂,它只是把前面设计好的四张表落成了SQLite表结构,并提供了写入方法。真实项目中,你可以在Agent执行循环里调用这些方法。比如调用工具前记录一下参数,拿到结果后再把结果更新进去。注意,我这里故意没有做“更新”操作,而是采用追加写的方式,因为证据表天然是追加型数据,不要轻易UPDATE,否则很容易覆盖历史。
3.3 刚开始就做追踪的坑
前面讲的是做法,这里我想专门说说新手前期容易踩的坑,我基本都踩过一遍。
第一个坑是只记录成功场景。很多人写埋点的时候,只在正常路径里加了日志,一旦Agent抛出异常或者工具调用失败,整个追踪链就断了。但恰恰是失败场景的证据最珍贵。我建议在Agent执行的异常处理器里也调用TraceManager,把异常信息、当前步骤、已完成的工具调用全部写进表里。只有失败的trace也是完整trace,离线回归才能覆盖异常场景。
第二个坑是序列化问题。工具的参数和返回值不一定都是JSON可序列化的,比如有的返回是Pandas DataFrame,有的是numpy数组,有的干脆是自定义对象。直接json.dumps会报错。我常用的处理方式是加一个default参数,把非序列化对象转成可读字符串,必要时截断超长内容,避免把数据库文件撑爆。这里面还有个细节:返回值过大时,建议只保存摘要或前N个字符,否则一个长文档检索结果几万字,两次测试数据库就几个G了。
第三个坑是不设版本。上面场景表里已经留了agent_version和model_name字段,但很多人一开始就是不填,后来版本迭代了才发现查不到差异。我的经验是,任何一次trace记录,都必须带着完整的包版本、模型版本、提示词版本信息。这种“多填一个字段”的习惯,能让你在后续做回归对比时节省大量时间。
4. 离线回归的执行流程与判定标准
4.1 回归脚本如何组织
证据链搭起来后,离线回归就水到渠成了。所谓离线回归,简单说就是准备好一批固定的测试场景,在Agent代码或配置发生变化后,把它们重新跑一遍,对比前后的tool trace行为和最终结果,看有没有退化。
回归脚本的组织,我建议用这个三层结构:
第一层是场景装载器,负责从本地文件或表里加载待测试的场景列表。每一个场景对应task_scene里的一条记录,包含输入、预期描述、期望工具调用路径等。
第二层是Agent执行器,负责实际调用Agent完成场景任务,并用TraceManager记录下新的trace。执行器要保证环境尽量隔离,比如每次运行前把临时状态清空,避免上一次运行的结果干扰下一次。
第三层是断言器,负责将新的trace和场景的预期行为做比对,给出回归结论并写入result_regression表。
我写回归脚本时,喜欢把场景清单放在一个JSON文件里管理,例如:
[ { "scene_name": "天气查询-北京", "init_input": "今天北京天气怎么样?", "expected_tool_sequence": ["search_city_id", "get_weather"], "expected_final_contains": ["北京"], "agent_version": "v0.3.1", "model_name": "gpt-4o-mini" } ]这样维护起来非常直观。新增一个场景就是往JSON里加一条记录,不用改任何代码。注意这里expected_tool_sequence是期望的工具调用顺序,它是离线回归里非常核心的断言条件。因为Agent决策质量的一个重要表现,就是它是否按照合理顺序调用工具,是否跳过必要步骤。
4.2 如何判断回归失败
回归的判定不能只靠“最终回复里有几个关键词”,要分级。我把回归结果分成四档:通过、失败、异常、待人工确认。
通过表示Agent的行为完全符合预期,工具调用顺序正确,最终回复包含必要信息,耗时在可接受范围内。失败表示出现了明确的逻辑错误,比如应该调用工具A却调用成了工具B,或者工具调用参数错误,或者最终回复缺少关键内容。异常表示Agent执行过程中出现报错、超时、API调用失败等非正常状态,这类通常不是Agent逻辑本身的问题,更像是环境问题,但也要记录在案。待人工确认表示自动判定拿不准,比如最终回复质量看着还行但思考过程明显不合理,这种情况下我选择标记出来让人审查,而不是武断判定成功。
为什么要有待人工确认这个档位?因为Agent的很多偏差是“隐性偏差”。比如它最终结果正确,但中间绕了一个很大的弯,调用了不相关的工具。这种问题靠自动断言很难识别,但你通过回归卡住它会很痛苦,会频繁误报。更好的做法是先把它标记为待人工确认,每周集中人工审查一批,把高频的隐性偏差提炼成新的自动断言规则。
从实操角度给个建议:判断失败前,先看一下tool_trace和step_reasoning两张表。如果Agent每一步都是有依据的,只是最后结果不符合预期,那可能是预期设定有问题;如果某一步的工具输入明显和场景无关,那才是真正的决策失败。把证据和结论对应起来,再写judge_basis字段,这个回归记录才有参考价值。
4.3 回归样本库维护
回归做一段时间后,你会面临另一个问题:场景越来越多,跑一次回归的时间和成本越来越高。这时候就要维护回归样本库了,原则是“全量覆盖低频跑,核心样本高频跑”。
我通常会把场景样本分成三个等级。P0是核心场景,比如登录、主流程查询、核心工具调用,每次代码变更都必须跑。P1是重要但非核心的场景,比如多条件组合查询、权限类场景,每次发版前跑。P2是边缘场景,比如超长输入、异常格式、特殊字符,每周跑一次就够。
这个分级不是固定的,要根据实际回归结果动态调整。如果某个P2场景连续几次都暴露出问题,就升成P1甚至P0。如果某个P0场景已经连续几十次稳定通过,可以适当降级,把执行时间让给新发现的高风险场景。
另外,样本库里的场景描述和预期行为要跟Agent能力同步更新。举个例子,你的Agent原本只支持单轮查询,后来升级成了支持多轮对话,那场景描述和预期行为都要跟着补上多轮交互的情况,否则回归库就是一套过时的试卷,考不出真实能力。
5. 常见问题与排查技巧实录
5.1 核心问题速查
我把实操中经常遇到的问题整理成了速查表,方便你排查时直接对照。
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Agent没有调用任何工具就回复结果 | 系统提示词里工具描述风格变化,或模型版本决策差异 | 比对step_reasoning,看模型是否意识到工具可用 |
| 工具调用顺序混乱 | Agent上下文过长导致注意力丢失 | 检查tool_trace的步骤序号和推理摘要 |
| 相同输入两次结果不一样 | 模型采样参数temperature偏高 | 固定采样参数,并记录到task_scene的配置版本中 |
| 工具参数出现幻觉值 | 模型编造了不存在的ID或日期 | 看step_reasoning里检索结果的上下文是否缺失 |
| 中间某一步工具超时 | 上游服务变慢或网络波动 | 检查tool_trace的cost_ms和status字段 |
| 回归脚本运行慢 | 场景过多、单场景执行耗时过长 | 用样本分级,高风险场景优先,批量并发执行 |
这里面很多问题,如果不看证据表是根本无法定位的。尤其是“工具参数出现幻觉值”,最终回复看起来可能完全正常,但查一下tool_trace里的args_json,就会发现Agent传了一个格式完全错误的ID进去。这种场景在真实项目中非常常见,也是离线回归能比人工测试更快发现的关键问题。
5.2 我踩过的坑
再分享几个我自己踩过的坑,比原理更有参考价值。
第一个坑是回归测试和Agent执行共用同一个数据库连接。Agent执行本身可能是异步的,或者多线程调度多个任务,如果多个线程往同一个SQLite文件里并发写,会出现“database is locked”错误。我的解决办法是给每个执行线程单独开连接,写完后关闭;或者干脆按traceID保存成一个独立db文件,最后再合并。对本地测试来说,简单可靠比性能重要。
第二个坑是只收集了最终trace,没有收集中间错误状态的现场信息。有一次Agent在工具调用后返回了一个特殊错误码,最终回复却很自然,说“操作已完成”。如果不看trace,会以为任务成功了,但事实上Agent根本没有拿到有效数据。后来我在log_tool_call里额外加了一个字段记录工具返回的原始错误码和错误消息,即使Agent强行继续生成,证据表里也能一眼识别出异常。
第三个坑是期望路径设定得太死。最早我做回归时,在expected_tool_sequence里写死了工具调用的每一步,结果一个小改动就让大量用例失败。后来我把期望分成了硬约束和软约束:硬约束是必须调用的工具和必须包含的输出项,软约束是推荐路径。只有硬约束命中失败才报错,软约束的偏离只是记为待人工确认。这样既保证了核心质量,又不会因为Agent合理的路径优化而误伤。
第四个坑是证据表只有本地文件,没有归档策略。开发早期无所谓,但项目跑了三个月后,光SQLite文件就堆了几百个。我后来养成了一个习惯:每次回归都生成一个带日期的目录,里面放着db文件和一份自动生成的回归摘要Markdown。这个归档习惯一开始看不出价值,等到项目出现一个历史性问题需要回溯时,你就知道多重要了。
还有一个值得尝试的小技巧:在离线回归中引入“双跑对比”。同一个场景,在修改前后各跑一遍,然后直接把两次tool_trace的JSON内容做diff。工具调用顺序变了、参数多了少了、耗时涨了跌了,一目了然。这个diff脚本写起来并不复杂,就是把两个trace按步骤序号的字段逐列对比,输出差异。它比只看最终结果判断“有没有退化”可靠得多,也是把证据表吃透之后最顺理成章的应用。
我个人在实际操作中的体会是,很多人觉得Agent开发难,不是难在写调用代码,而是难在“System 2式的调试”——你没法靠肉眼观察内部思维过程,只能靠外部记录来还原。而这套4张证据表的组合拳,恰恰就是给Agent装了一个行车记录仪。初级开发者完全有能力自己做出来,只要肯花一个下午把表结构建好、埋点插好,后面所有的排障和回归都会轻松一个量级。如果以后你的Agent开始接多Agent协作、异步任务、长周期任务,这套本地证据表同样可以作为底座,继续扩展出事件表、消息传递表这些新结构。先把基础的证据链跑通,后面的路就好走很多。