AI Agent自动校验脚本实战:从Demo到生产环境的稳定之道

发布时间:2026/10/2 19:48:07
AI Agent自动校验脚本实战:从Demo到生产环境的稳定之道 做AI Agent开发的朋友应该都经历过这么一幕本地Demo里各种工具有条不紊对话流程顺畅得一塌糊涂演示的时候大家连连点头结果一上正式环境半小时之内就翻车。不是工具选错就是参数传歪严重的直接卡进死循环。我最初接手一个客户服务Agent时就是这样Demo确实跑通了上线之后却冒出各种幺蛾子。后来我用Python做了一套自动校验脚本把Agent的每一次工具调用、每一步状态变化都记录下来在发布前跑回归测试。这篇内容就把这套思路和实现细节整理出来包含校验脚本的设计逻辑、核心代码、踩坑记录适合正在做Agent工程化、或者想给Agent补上测试体系的朋友参考。先说清楚一个前提Agent这类系统和普通后端服务不一样它的核心是模型调度天然带随机性。你本地跑十次可能都顺顺利利但只要概率分布的尾部落一次到“错误路径”上生产环境的用户就会看到一次不可原谅的翻车。自动校验脚本解决的就是这个问题不只看最终答案而是把Agent的行为全过程变成可量化的指标在发布前把“这次改动会不会引入回归”这个问题回答清楚。1. Demo和上线的差距翻车点到底在哪很多人觉得“我Demo都通了上线跑一下不就行了”这种想法在Agent项目里特别危险。Demo阶段你有充足时间去试、去调甚至可以反复重试但生产环境里Agent面对的是真实用户的随机输入、真实API的延迟抖动、还有模型服务商随时可能发生的小规模行为变化。上线翻车通常不是某一个环节崩了而是几个隐性差距叠加造成的。1.1 模型非确定性Demo只是极少数的一次采样大语言模型本质上是概率生成器温度设为0也不是绝对确定尤其在长链路Agent任务里一点点采样差异就可能让输出结构完全不同。我在本地调Demo的时候同一个问题往往只试三四次恰好在那个运气区间内所有中间结果都正常。可生产环境每分钟几千次调用小概率的“异常生成”就变成了必然事件。这里有一个很关键的认知Demo阶段你看到的是模型“能”表现得多好生产阶段你要关心的是模型“偶尔”会表现得多差。自动校验脚本要做的就是把这个“偶尔”抓出来让它在测试环境先暴露而不是上了线才暴露。另外模型服务的版本升级也是隐蔽的坑。同样一个Prompt换了模型版本之后可能输出里多了一个字段、少了一个字段甚至JSON格式从稳定变成偶尔带Markdown代码块。没有自动校验这种变化你根本感知不到用户却已经碰到了解析失败。1.2 工具调用链条从“能用”到“稳固”的鸿沟Demo阶段一般只挂两三个工具参数简单模型很容易选对。生产环境里一个客服Agent可能要面对十几个工具查订单、查物流、算退款、转人工、查优惠券、改地址……工具越多模型“选错工具”的概率就越大。更麻烦的是生产环境的工具参数schema通常是从真实接口生成的动辄十几个字段还有一些枚举值、必填项、条件约束。模型只要漏传一个参数或者传了一个根本不存在的枚举值整个调用链就断了。我见过最典型的一次事故是Agent在Demo里只有一个查天气的工具参数只有城市名。生产环境接入了企业内部的ERP查询工具后模型经常把“订单编号”和“物流单号”搞混导致用户查物流的时候拿到的是订单信息而且因为它“看起来成功”了Agent还会自信地告诉用户物流正在运输中。这个问题的根源就是工具调用参数的合法性完全没有被校验。1.3 环境差异本地、测试、生产是三个世界本地能跑通不代表测试环境能跑通更不代表生产能跑通。网络白名单、API Key权限、外部服务限流、模型服务的超时配置这些环境因素在Demo阶段根本不会暴露。比如本地直连外部API很顺畅测试环境走的是公司网关网关对请求体大小有限制Agent一次塞了太多上下文过去直接被网关拒掉整个流程就卡住了。自动校验脚本的价值在这里就体现出来了它不只是跑通测试用例还能把“这次外部调用花了多久”“有没有被限流”“返回结果有没有被截断”这些环境相关指标记录下来让你在发布前就发现环境差异导致的问题。2. 自动校验脚本的整体设计思路设计这套脚本之前我一直在想一个问题普通的单元测试和接口测试能不能直接套用到Agent上答案是能套一部分但核心思路必须转换。普通测试断言的是“输入-输出”的确定性关系Agent测试断言的是“行为轨迹”是否符合预期。这就像验收一个新来的同事不只看他最后交的东西还要看他在过程中怎么处理异常、有没有做危险操作、有没有在一件事上反复纠结。2.1 校验Agent和校验普通函数有什么不同普通函数是确定性的给它一组输入必然得到一组输出测试断言可以直接写死。Agent不是同一个问题它可能今天选这个工具明天选那个工具今天用三步解决明天用五步绕一圈。如果你在测试里写死“必须调用某个工具”那这个测试大概率会不稳定因为你校验的不是Agent的行为本质而是某一次采样的运气。所以我的校验脚本确立了一条核心原则校验过程是否安全合规、结果是否满足约束、资源消耗是否合理而不是校验某条具体路径是否被严格命中。举个例子不写“必须调用查订单工具”而是写“Agent在处理订单查询请求时调用过的工具列表中必须至少包含一个具备订单查询能力的工具且禁止调用退款工具”。这里还有个额外的好处这种校验方式对Agent框架的升级更宽容。你换了Prompt策略、调整了工具描述甚至替换了模型提供方只要行为符合预期测试依然能过。反之如果你的测试断言写得太死每次优化Agent都会带出一堆假阳性失败最后团队就没人愿意跑测试了。2.2 五个校验维度和它们的判定标准我最终把校验拆成了五个维度每个维度都有量化指标。这五个维度覆盖了Agent从接收输入到产出最终回复的完整生命周期。第一个维度是工具调用合法性。包括工具名是否存在于已注册工具集、参数是否符合JSON Schema、参数中的枚举值是否合法、必填字段是否缺失、调用结果是否被正确解析。这个维度主要靠jsonschema库和工具注册表做校验。第二个维度是路径合理性。看的是Agent整轮任务中是否出现重复调用同一工具超过N次、是否调用了未被授权的高危工具、是否在一个工具失败后反复重试而不换方案。死循环和无效重试在这个维度被直接抓出来。第三个维度是上下文管理。记录每一轮对话的Prompt Token消耗、历史消息长度、上下文被截断的位置。如果Agent早期接收的关键信息在某轮之后被截断了后面它给出的回复会明显偏离需求这种问题单看最终输出很难发现但看轨迹非常清楚。第四个维度是错误恢复能力。构造工具调用失败、返回异常数据、超时等场景观察Agent是正确降级、换一条路走还是把错误信息原样丢给用户。生产环境里外部API一定会挂这个维度跑的就是“挂的时候Agent能不能体面”。第五个维度是回复质量约束。虽然LLM的输出非确定但仍有一些硬约束可以校验回复是否包含用户要求的关键字段、是否在指定格式内比如JSON/表格、是否引用了不存在的订单号、语气是否包含危险词汇。我用一类“软断言”来处理这个维度不做全局校验而是针对具体场景定义关键信息列表只要这些信息没丢就算通过。这里有一个非常重要的提醒五个维度全部通过才代表这个场景可以发布任何维度失败都值得你停下来看一眼而不是盲目重跑赌运气。我见过不少团队把Agent测试做成了“跑三遍能过一次就算过”这种做法在稳定性要求高的场景里风险极大。3. 核心实现把校验脚本真正跑起来思路定了剩下的就是代码。我下面把这次实践中真正沉淀下来的核心代码片段拆开讲每一段都来自我实际项目里跑过几百轮的版本可以直接参考改造成你自己的校验工具。3.1 搭建一个可观测的Agent运行底座校验脚本的第一步不是写断言而是让Agent的每一次行为可见。我用一个工具调用的包装器来接管所有外部工具把每次调用的工具名、参数、返回结果摘要、耗时、异常全部记录下来。这里不依赖具体的Agent框架只要你的工具是通过普通Python函数注册的就可以套这个模式。import time import json from dataclasses import dataclass, field dataclass class ToolCallRecord: tool_name: str args: dict result_preview: str duration_ms: float error: str | None None class ToolCallRecorder: def __init__(self): self.records: list[ToolCallRecord] [] def wrap(self, func): def wrapper(*args, **kwargs): start time.time() try: result func(*args, **kwargs) error None except Exception as e: result error str(e) duration_ms (time.time() - start) * 1000 record ToolCallRecord( tool_namegetattr(func, __name__, unknown_tool), argskwargs, result_previewstr(result)[:500], duration_msduration_ms, errorerror, ) self.records.append(record) return result return wrapper def reset(self): self.records.clear()用这个Recorder把Agent的每个工具包一层Agent跑的时候不会感知到任何变化但你的测试代码手里就多了一份完整的调用轨迹。为什么要截断result_preview防止巨大返回结果把日志和校验步骤拖垮测试脚本本身也要控制资源消耗。每一次测试跑完我还会把records序列化到JSON文件里。这么做有两个理由失败时可以保留现场、定位问题每次发版做diff对比时也能清楚看到“这次改动让行为轨迹发生了哪些变化”。3.2 用JSON Schema校验每一个工具调用工具调用参数是Agent出错的高发区。我的做法是给每个工具维护一份JSON Schema然后用jsonschema库校验Recorder捕获的参数。这个方案的好处是你不需要针对每个工具写单独的校验函数一份Schema声明一次所有工具共用同一个校验逻辑。import jsonschema TOOL_SCHEMAS { query_order: { type: object, properties: { order_id: {type: string, pattern: ^ORD\\d{8}$}, }, required: [order_id], }, query_logistics: { type: object, properties: { order_id: {type: string, pattern: ^ORD\\d{8}$}, tracking_number: {type: string}, }, oneOf: [ {required: [order_id]}, {required: [tracking_number]}, ], }, apply_refund: { type: object, properties: { order_id: {type: string}, amount: {type: number, minimum: 0.01}, reason: {type: string, minLength: 2}, }, required: [order_id, amount, reason], }, } def validate_tool_calls(records: list[ToolCallRecord]) - list[str]: errors [] for record in records: schema TOOL_SCHEMAS.get(record.tool_name) if schema is None: errors.append(f工具不存在或未注册: {record.tool_name}) continue try: jsonschema.validate(record.args, schema) except jsonschema.ValidationError as e: errors.append(f工具 {record.tool_name} 参数非法: {e.message}) return errors我这里特意给了query_logistics一个oneOf约束意思是模型既可以按订单号查也可以按运单号查。这类条件约束特别贴近真实业务也最容易暴露模型“能力不足”它常常会把两个字段同时塞进去或者一个都不给让工具直接报错。Schema校验能在上线前就把这些不合理的调用模式找出来。有一点要补充校验脚本里发现的参数错误不能只看成一个“脏数据”。它往往意味着Agent的工具描述写得不够清晰或者模型没有正确理解字段含义。正确做法是拿着校验报告去优化工具描述而不是简单加一次重试逻辑。重试虽然能让单次成功率上去但会让错误模式隐藏在日志里后面再想清理就难了。3.3 状态一致性和上下文约束校验Agent系统的另一个大坑是状态丢失。多轮对话场景里用户在第一轮提供了订单号Agent可能在第三轮就忘了。为了抓这种问题我写了一个简单的状态追踪器配合校验函数检查关键信息是否在后续轮次中被正确传递。class StateConsistencyChecker: def __init__(self, key_fields: dict[str, str], max_history: int 20): self.key_fields key_fields self.max_history max_history def check(self, conversation: list[dict], tool_calls: list[ToolCallRecord]) - list[str]: errors [] seen_fields {} for message in conversation: content message.get(content, ) for field, alias_list in self.key_fields.items(): for alias in alias_list: if alias in content and field not in seen_fields: seen_fields[field] message[role] for field, alias_list in self.key_fields.items(): if field not in seen_fields: errors.append(f关键业务字段缺失: {field}) return errors实际项目里key_fields会根据场景配置比如查订单场景里关键的字段是order_id和用户身份标识。这个检查器解决的问题是就算Agent最后回复得头头是道但只要关键业务字段在过程中没出现过它很可能是在“一本正经地胡编”。上线前用这种硬约束卡一道能过滤掉相当一部分幻觉问题。上下文长度约束同样重要。我监控每个场景跑完全程后的Token消耗如果Prompt长度经常逼近模型窗口上限那么早期信息被截断的风险就极高。最稳妥的做法是给每条业务链路上限做压测比如“完整处理一笔退款历史记录不超过15轮”超出上限的用例直接判失败。这个约束能逼着你去优化上下文压缩策略而不是把上下文膨胀的问题留到线上。3.4 自动化用例组织和报告输出有了上面的组件剩下的就是怎么组织用例和输出结果。我这里推荐直接用pytest原因很简单用例隔离、断言失败信息清晰、和CI集成几乎没有成本。import pytest from agent_harness import ToolCallRecorder, run_agent pytest.fixture def recorder(): r ToolCallRecorder() yield r r.reset() def test_normal_order_query(recorder): # 模拟用户输入运行Agent conversation run_agent( user_input帮我查一下订单 ORD20250101001 现在到哪了, recorderrecorder, ) # 1. 校验工具调用合法性 assert validate_tool_calls(recorder.records) [] # 2. 校验是否存在死循环同一个工具最多调用3次 tool_counts {} for rec in recorder.records: tool_counts[rec.tool_name] tool_counts.get(rec.tool_name, 0) 1 assert max(tool_counts.values()) 3, f工具调用次数异常: {tool_counts} # 3. 校验关键信息没有丢失 checker StateConsistencyChecker( {order_id: [ORD20250101001]} ) assert checker.check(conversation, recorder.records) []这些用例跑完之后我会加一个conftest.py把报告汇总成JSON包含每个用例的通过/失败原因、每个工具调用的耗时分布、异常调用明细。报告有历史记录的话还能对比两次发版之间的调用次数变化和耗时变化提前发现性能退化。用例隔离是个容易被忽略的点。Agent测试比普通单元测试更需要隔离每条用例跑完必须清空Recorder、清掉会话历史有条件的话还要重置模型服务的上下文缓存。我之前漏了这一步导致第二条用例跑的时候第一条用例的状态残留混了进来莫名其妙多了一次工具调用排查了很久才发现是状态污染。4. 实操中的常见问题与排查记录这套脚本从写出来到稳定运行我踩了不少坑。下面几条是当时排查最久、也最影响测试可信度的问题单独拿出来分享。4.1 测试结果不稳定随机性是头号敌人校验脚本跑第一次失败了第二次又通过这种“闪断”是Agent测试最让人头疼的问题。一开始我以为是代码bug后来发现根源在于模型采样本身。你可以把Agent的温度参数调成0减少一部分随机性但长上下文任务里模型对输入顺序的微小差异依然会做出不同选择。我后来采取了两个策略。第一给每条用例设置失败重跑上限默认跑三遍三遍中最差的那一遍作为最终结果。不要取最好的一次取最差的这样对生产风险更诚实。第二如果同一个用例三遍跑下来结果不一致我会把三份轨迹diff打印出来看差异发生在哪一步。绝大部分差异都集中在Agent对工具选择的犹豫上这时候最优解是优化工具描述让模型更快做出决定而不是盲目在代码里加断言去掩盖差异。4.2 用例之间的状态污染这个问题在前面提过但它值得多说两句。Agent框架的会话历史在测试里很难自动清理尤其是使用了全局单例模型客户端的时候。Cassical的表现是第二条用例的对话里出现了第一条用例的订单号Agent误以为用户在追问旧订单完全不进入新场景。我的解决办法是给每条用例起一个全新的Session ID框架层面强制隔离会话上下文。如果你用的Agent框架不支持多会话那至少要在用例fixture里显式调用会话清理接口并且在断言里校验关键业务字段是否在正确的会话里出现。这个坑好几个同事都踩过不是代码写错是测试基建没做隔离。4.3 超时、资源泄漏与外部API波动Agent测试里最常见的超时来源不是Agent本身而是它调用的外部API。本地测试时外部接口响应很快一放到CI环境网关多一跳耗时直接翻倍。如果校验脚本里把“工具调用耗时”也当成硬指标很容易出现假阳性失败。我调整的策略是把耗时类指标分成两档警告档只记录不进断言失败档才是真正的超时上限。比如单次工具调用超过10秒记录为warning超过30秒才算失败。这样既不会漏掉性能劣化又不会被环境波动干扰结果。外部API稳定性问题则要靠mock来兜底。我维护了一套基于responses库的API mock层所有外部依赖都可以在测试中切换成mock模式。mock模式的返回值里会故意注入几种异常数据空结果、超长字段、格式错误。这些异常输入就是用来考察Agent错误处理能力的素材比真实环境更容易复现。真实API测试我也保留了但频率降低到每次发版前跑一轮冒烟即可。5. 把校验脚本嵌入日常开发流程脚本写得再好如果只在手忙脚乱的时候想起来跑一次价值也大打折扣。我的做法是把这套校验脚本接入了CI流程并且给N修改Agent代码加了门禁。5.1 在CI里跑校验但要控制时间和成本Agent测试比普通单元测试慢很多因为每次都要真实调用模型一轮跑下来十分钟很正常。为了不让CI排队排到怀疑人生我把用例分成了三个级别P0级别10个核心场景每次提交必须全跑P1级别50个常规场景每天定时跑一次P2级别扩展场景只在发版前全量跑。这种分层策略让日常开发反馈保持在几分钟内同时又保证了发版前的覆盖度。成本问题也要考虑模型调用费用不是小数。P0用例全部用最小模型配置跑只在怀疑问题是模型能力不足时才切到大模型复验。这个策略帮我省了不少预算。5.2 维护一套“黄金场景集”所谓黄金场景就是覆盖Agent所有核心业务路径的那组用例。它们不仅要在每次改动后跑还要在每次升级模型版本、调整系统提示词、新增工具后跑。我把这套场景集的维护责任落实到具体业务owner身上每个新功能上线前owner必须把新的黄金场景补进用例集。这个机制看着简单却是整套校验脚本能持续发挥作用的关键。我见过太多团队写了测试脚本但没人加新用例最后测试覆盖不到新功能形同虚设。5.3 先mock后联调两层校验都别省最后强调一点mock校验和真实联调校验要分层跑不能混在一起。Mock用例适合放在CI里高频跑保证Agent行为逻辑稳定真实联调用例则适合放在发版前后跑专门暴露环境差异和外部接口契约变更。两个层次的测试目的不同混在一起会让失败原因变得很难定位你分不清到底是Agent逻辑错了还是外部接口返回变了。我自己习惯的节奏是开发阶段全程mock合入主干前跑一遍P0真实联调发版当天早上跑完整P2集。这套节奏跑了快半年生产事故从每个月两三次降到了两个月一次而且那一次还是第三方接口文档变更没同步导致的契约问题校验脚本其实已经帮忙拦截了大部分风险。在我个人的实践体会里给Agent做校验脚本这件事真正改变的不是“少出bug”这个表面结果而是让我对自己做出来的系统有了一个诚实的认知Demo跑通只能说明“运气好”只有校验脚本全绿才说明“大概率稳”。如果你也在做Agent项目建议从最小的一步开始先写一个Recorder把工具调用轨迹记录下来加一条Schema校验然后慢慢扩展。这件事的投入产出比比我预想的高太多了。