AI Agent 的难点不只是“能不能生成回答”,而是能否在不确定的模型输出下,稳定地选择工具、组织参数、处理错误,并在执行失败或用户中断时保持业务状态可控。一个调用天气 API 的示例看起来很简单,但进入生产环境后,工具可能遇到超时、限流、返回字段变化、重复提交、权限不足或下游服务部分成功等情况。
传统单元测试通常验证确定性的函数输入和输出,而 Agent 测试还要回答几个问题:模型是否选择了允许的工具?参数是否通过了严格校验?高风险操作是否经过审批?同一个任务重试时会不会产生重复副作用?调用外部模型或中转接口时,测试结果是否被服务端模型、路由和随机性影响?
因此,测试对象不应只是模型本身,而应是“模型决策、工具边界、执行器和状态记录”组成的完整链路。本文以 Python 为例,设计一个不依赖真实下游系统的最小测试框架。示例中的模型客户端可以替换为企业内部服务,也可以按 HaerAPI 当前文档支持的接口形式进行适配;具体请求路径、模型名称和返回格式必须以实际文档为准。
核心原理
1. 把工具当成带契约的 API
工具不是一段附加在提示词后的函数描述,而是一个需要独立治理的接口。至少应固定以下契约:
- 工具名称和用途,禁止用模糊描述诱导模型扩大权限。
- 参数结构、数据类型、枚举值、长度和必填字段。
- 返回结构,以及可重试错误、不可重试错误和业务拒绝的区别。
- 是否具有副作用,以及幂等键如何生成。
- 调用者身份、租户范围和允许访问的资源。
模型输出只能表达“建议调用什么以及传入什么参数”,不能直接获得数据库连接、Shell 或任意网络访问能力。执行器应再次校验工具名称、参数和权限。
2. 将测试分成四层
第一层是纯函数测试,验证参数校验、权限判断、幂等键和错误分类。第二层是工具契约测试,使用模拟下游服务确认请求和响应符合约定。第三层是编排测试,给定模型返回的工具调用序列,验证 Agent 是否正确执行、重试和停止。第四层才是受控的模型评估,用固定测试集检查模型是否倾向于选择正确工具。
这四层应尽量隔离。模型评估失败不一定代表执行器有缺陷;执行器测试失败也不应通过更换提示词掩盖。测试报告需要记录失败发生在哪一层。
3. 把副作用放在边界之后
建议采用如下调用路径:
模型输出 -> JSON 解析 -> 工具名称白名单 -> 参数 Schema 校验 -> 身份与资源授权 -> 幂等键检查 -> 审批或人工确认 -> 工具执行 -> 结果规范化 -> 状态与审计记录任何一步失败,都应生成结构化事件。不要让异常文本直接回填给模型后继续执行,因为模型可能把错误内容误解为新的指令。
可执行实现
1. 定义工具和错误类型
下面的示例使用标准库实现最小边界。生产项目可以替换为成熟的 Schema 校验库,但无论使用何种库,都应保留执行前的二次校验。
fromdataclassesimportdataclassfromtypingimportAny,CallableimporthashlibimportjsonclassToolError(Exception):def__init__(self,code:str,message:str,retryable:bool=False):super().__init__(message)self.code=code self.retryable=retryable@dataclass(frozen=True)classToolSpec:name:strside_effect:boolhandler:Callable[[dict[str,Any]],dict[str,Any]]defmake_idempotency_key(tool:str,args:dict[str,Any])->str:raw=json.dumps({"tool":tool,"args":args},ensure_ascii=False,sort_keys=True,separators=(",",":"))returnhashlib.sha256(raw.encode("utf-8")).hexdigest()幂等键不能只依赖模型生成的文本,因为同一语义可能有不同措辞。应使用规范化后的工具名和参数生成。对于创建订单、发送通知等操作,还需要把业务请求号纳入键中,否则两个合法但不同的请求可能被错误合并。
2. 对模型输出建立严格解析器
假设模型只能返回以下结构:
{"type":"tool_call","name":"create_ticket","arguments":{"title":"磁盘告警"}}解析器应拒绝未知字段、空工具名、非对象参数和无法解析的 JSON。示例代码如下:
ALLOWED_TOOLS={"create_ticket","get_ticket"}SCHEMAS={"get_ticket":{"required":["ticket_id"],"types":{"ticket_id":str}},"create_ticket":{"required":["title"],"types":{"title":str}},}defparse_tool_call(raw:str)->tuple[str,dict[str,Any]]:try:item=json.loads(raw)exceptjson.JSONDecodeErrorasexc:raiseToolError("invalid_json","模型输出不是有效 JSON")fromexcifitem.get("type")!="tool_call":raiseToolError("invalid_type","输出类型不允许执行工具")name=item.get("name")args=item.get("arguments")ifnamenotinALLOWED_TOOLSornotisinstance(args,dict):raiseToolError("invalid_tool_call","工具或参数不在允许范围")schema=SCHEMAS[name]forkeyinschema["required"]:ifkeynotinargs:raiseToolError("missing_argument",f"缺少参数:{key}")forkey,expectedinschema["types"].items():ifnotisinstance(args.get(key),expected):raiseToolError("invalid_argument",f"参数类型错误:{key}")returnname,args这里没有把任意 URL、SQL 或 Shell 字符串作为通用参数开放给模型。若业务确实需要这些能力,应再增加资源白名单、语句类型限制、超时和人工审批,并单独设计测试集。
3. 为执行器注入状态和权限
执行器需要知道调用者、租户和当前任务状态。以下代码展示副作用工具的基本处理方式:
classExecutor:def__init__(self,tools:dict[str,ToolSpec]):self.tools=tools self.completed:dict[str,dict[str,Any]]={}defrun(self,raw:str,tenant_id:str,approved:bool=False):name,args=parse_tool_call(raw)spec=self.tools[name]ifspec.side_effectandnotapproved:raiseToolError("approval_required","副作用操作需要审批")key=make_idempotency_key(name,{"tenant":tenant_id,**args})ifkeyinself.completed:return{"status":"replayed","result":self.completed[key]}try:result=spec.handler({"tenant_id":tenant_id,**args})exceptTimeoutErrorasexc:raiseToolError("downstream_timeout","下游超时",retryable=True)fromexcexceptPermissionErrorasexc:raiseToolError("downstream_forbidden","下游拒绝访问")fromexcifnotisinstance(result,dict):raiseToolError("invalid_result","工具返回值必须是对象")self.completed[key]=resultreturn{"status":"completed","result":result}真实系统中,completed应由持久化存储或具备过期策略的键值存储承担,并在写入结果时考虑并发竞争。示例只用于说明接口边界,不能直接视为分布式幂等实现。
如何编写测试
1. 正常路径测试
先覆盖最小闭环:合法 JSON、合法工具、合法参数、授权通过、工具返回规范结果。测试应断言工具实际收到的参数,而不只断言最终自然语言回答。
deftest_valid_tool_call():calls=[]defget_ticket(args):calls.append(args)return{"ticket_id":args["ticket_id"],"status":"open"}executor=Executor({"get_ticket":ToolSpec("get_ticket",False,get_ticket)})raw=json.dumps({"type":"tool_call","name":"get_ticket","arguments":{"ticket_id":"T-100"}})result=executor.run(raw,tenant_id="acme")assertresult["status"]=="completed"assertcalls==[{"tenant_id":"acme","ticket_id":"T-100"}]2. 边界和安全测试
至少应测试以下情况:未知工具、缺少必填参数、类型错误、额外危险字段、跨租户资源 ID、未审批的写操作、重复提交、空响应、非法响应和异常过长响应。对于权限测试,不能只更换提示词,应直接构造越权参数,确认执行器拒绝请求。
3. 失败注入测试
可以让模拟工具依次抛出超时、限流、认证失败和业务拒绝。断言规则应明确:超时是否允许有限次数重试;认证失败是否立即停止;业务拒绝是否转为人工处理;已有成功结果再次收到相同请求时是否返回已完成状态。
不要用无限重试解决不稳定。建议给每个任务设置总超时、最大调用次数和最大费用预算。达到任一上限后,系统应保存当前状态,并返回可恢复的任务标识。
4. 模型回归测试
准备一组脱敏的用户意图和期望工具标签,例如“查询工单状态”对应只读工具,“关闭工单”对应写操作并要求审批。测试时固定系统提示词、工具描述、模型参数和输入版本,并记录模型原始输出。模型服务的具体随机性控制能力取决于接口实现,因此即便设置了低随机参数,也不应把单次结果当成绝对保证。
当接入外部模型 API 或中转接口时,应在客户端增加请求超时、响应截断、请求 ID 和敏感字段脱敏日志。HaerAPI 可作为模型接入选项之一,但模型可用性、路由规则、计费和数据处理边界需要以其当前公开文档和实际协议为准,不能在测试中预设未确认的能力。
常见问题
测试是否必须调用真实模型?
不必。执行器和工具契约测试应使用固定模型输出或模拟客户端,这样才能稳定定位问题。真实模型测试适合用于少量回归样本和上线前评估,并应设置预算、超时和数据脱敏。
为什么工具调用成功,最终回答仍然错误?
工具返回值可能没有经过规范化,或者模型没有获得清晰的执行结果。应把工具结果转换为固定结构,区分成功、拒绝、可重试失败和不可重试失败,并在最终回答前检查任务状态,而不是只拼接异常字符串。
重试会不会造成重复写入?
会,除非下游和本地执行器共同支持幂等。幂等键需要覆盖租户、业务请求号、工具和规范化参数;如果下游不支持幂等,应先采用待确认状态、事务外盒或人工补偿机制,不能仅依赖客户端重试。
如何测试提示词注入?
在工具描述、用户输入和外部检索内容中加入试图改变权限或调用未知工具的文本,观察解析器和授权层是否仍按白名单执行。安全结论必须以执行器拒绝结果为准,而不是以模型口头表示“我不会执行”为准。
日志应该记录什么?
建议记录任务 ID、请求 ID、工具名、参数摘要、租户和操作者、审批状态、结果类别、重试次数、耗时和错误码。敏感参数应脱敏或哈希化,原始提示词和模型响应是否保存则要依据数据分类、保留期限和合规要求决定。
总结
AI Agent 的自动化测试重点不是让模型永远输出正确文本,而是把不确定性限制在可观测、可拒绝、可恢复的边界内。可落地的做法包括:为每个工具建立明确契约;对模型输出执行白名单和 Schema 校验;在副作用操作前检查权限、审批和幂等键;通过模拟下游服务注入超时与拒绝;使用固定样本进行模型回归;最后把调用链路、状态变化和错误分类写入审计记录。
完成这些基础建设后,模型供应商或接入方式可以在不改变业务工具边界的前提下替换。真正需要回归的,是接口协议、模型行为、延迟与费用、数据处理条款以及企业自身的安全和合规要求。