31|测试与评估:AI 应用怎样才算“可用”?
《Eino 实战:用 Go 构建 AI 应用与智能体》· 第 31 篇
关键词:单元测试、接口注入、效果评估、回归、成本指标
前置条件:已完成 15|RAG 优化 与 28|Callbacks 与可观测性。
这是最后一篇,回答一个贯穿全书的问题:怎么知道它真的能用?
AI 应用的测试比传统服务棘手,因为有两类完全不同的东西要验证:
| 类型 | 特征 | 验证方式 |
|---|---|---|
| 确定性代码 | 输入输出可预期 | 传统单元测试,断言相等 |
| 模型效果 | 输出多样、不保证复现 | 评测集 + 检查清单 |
混在一起测是常见错误:要么把效果问题当成代码 bug,要么因为“输出不固定”就放弃了测试。
一、先让代码可测:依赖接口而非具体实现
这是可测性的前提。看这段设计:
typeSummarizerstruct{Model model.BaseChatModel// 依赖接口}因为依赖的是接口,测试时可以注入模拟实现:
typestubModelstruct{replystringerrerror}func(m*stubModel)Generate(_context.Context,_[]*schema.Message,_...model.Option)(*schema.Message,error){ifm.err!=nil{returnnil,m.err}returnschema.AssistantMessage(m.reply,nil),nil}这个手法在第 06 篇就出现过(mockChatModel),现在可以正式说明它的意义:
用模拟模型替换真实调用,就能在不联网、不花钱、结果可复现的条件下,验证所有确定性逻辑。
包括:输入校验、错误传播、空结果处理、超时行为、状态流转。这些才是真正的代码 bug 高发区。
二、第一类:确定性代码测试
funcTestSummarize_RejectsEmptyInput(t*testing.T){s:=&Summarizer{Model:&stubModel{reply:"不该被调用"}}if_,err:=s.Summarize(context.Background()," ");err==nil{t.Fatal("空输入应当报错")}}funcTestSummarize_PropagatesModelError(t*testing.T){sentinel:=errors.New("model down")s:=&Summarizer{Model:&stubModel{err:sentinel}}_,err:=s.Summarize(context.Background(),"打印机离线")if!errors.Is(err,sentinel){t.Fatalf("应当传播原始错误,got %v",err)}}两类用例特别值得写:
| 用例类型 | 为什么重要 |
|---|---|
| 边界输入 | 空串、超长、特殊字符——最容易忘记处理 |
| 错误传播 | 确认错误没有被吞掉(第 04、11 篇都强调过) |
errors.Is(err, sentinel)这个断言很有价值:它验证的是错误链是否完整,而不是只看“有没有报错”。错误被吞掉是线上排查困难的头号原因。
三、第二类:效果评测
模型的输出无法逐字断言,但可以检查关键要素:
typeevalCasestruct{namestringticketstringstubstringrequires[]string// 必须出现的关键要素forbids[]string// 不得出现的内容(如编造根因)}两个维度的断言:
| 维度 | 例子 |
|---|---|
requires(必须有) | 摘要应保留“打印机”“离线”“合同”这些事实 |
forbids(不能有) | 摘要不得编造根因(如“驱动损坏”“硬件故障”) |
forbids这一维度在 AI 应用里尤其重要。传统测试关注“输出对不对”,AI 应用还要关注“有没有编造”。
在真实模型上的评测集
上面的示例用 stub 保证可复现。在真实模型上评测时,同一套结构仍然适用,只是数据要来自真实调用:
问题 期望要点 不得出现 打印机离线怎么办 网线、网段 编造硬件故障 怎么申请 VPN 审批、网络组 承诺即时开通 工单多久没处理可催办 24 小时 编造其他时限 报销流程是什么 (应拒答) 任何具体流程最后一行是关键:必须有“资料里没有”的用例,测试系统会不会编。
四、完整示例
下面这份测试可以直接运行(go test ./...),涵盖确定性逻辑与效果清单两类(已实机运行,6 个用例全部通过):
packageeval_testimport("context""errors""strings""testing""github.com/cloudwego/eino/components/model""github.com/cloudwego/eino/schema")// ---- 被测对象:一个可测试的摘要逻辑 ----// Summarizer 依赖接口而非具体实现,因此可以注入模拟模型。typeSummarizerstruct{Model model.BaseChatModel}func(s*Summarizer)Summarize(ctx context.Context,ticketstring)(string,error){ticket=strings.TrimSpace(ticket)ifticket==""{return"",errors.New("ticket is empty")// 输入校验:确定性逻辑}msg,err:=s.Model.Generate(ctx,[]*schema.Message{schema.SystemMessage("请把工单整理成不超过三行的摘要。"),schema.UserMessage(ticket),})iferr!=nil{return"",err}ifmsg==nil||strings.TrimSpace(msg.Content)==""{return"",errors.New("empty model output")}returnstrings.TrimSpace(msg.Content),nil}// ---- 模拟模型:替代真实调用,使测试无需网络与密钥 ----typestubModelstruct{replystringerrerror}func(m*stubModel)Generate(_context.Context,_[]*schema.Message,_...model.Option)(*schema.Message,error){ifm.err!=nil{returnnil,m.err}returnschema.AssistantMessage(m.reply,nil),nil}func(m*stubModel)Stream(ctx context.Context,in[]*schema.Message,opts...model.Option)(*schema.StreamReader[*schema.Message],error){msg,err:=m.Generate(ctx,in,opts...)iferr!=nil{returnnil,err}sr,sw:=schema.Pipe[*schema.Message](1)gofunc(){defersw.Close();sw.Send(msg,nil)}()returnsr,nil}// ---- 第一类测试:确定性代码逻辑 ----funcTestSummarize_RejectsEmptyInput(t*testing.T){s:=&Summarizer{Model:&stubModel{reply:"不该被调用"}}if_,err:=s.Summarize(context.Background()," ");err==nil{t.Fatal("空输入应当报错")}}funcTestSummarize_PropagatesModelError(t*testing.T){sentinel:=errors.New("model down")s:=&Summarizer{Model:&stubModel{err:sentinel}}_,err:=s.Summarize(context.Background(),"打印机离线")if!errors.Is(err,sentinel){t.Fatalf("应当传播原始错误,got %v",err)}}funcTestSummarize_RejectsEmptyModelOutput(t*testing.T){s:=&Summarizer{Model:&stubModel{reply:" "}}if_,err:=s.Summarize(context.Background(),"打印机离线");err==nil{t.Fatal("模型返回空内容时应当报错")}}funcTestSummarize_HappyPath(t*testing.T){s:=&Summarizer{Model:&stubModel{reply:"三楼打印机离线,已影响合同打印。"}}out,err:=s.Summarize(context.Background(),"打印机离线")iferr!=nil{t.Fatal(err)}ifout==""{t.Fatal("摘要不应为空")}}// ---- 第二类测试:效果评测(需要判断,不能只断言相等) ----typeevalCasestruct{namestringticketstringstubstringrequires[]string// 必须出现的关键要素forbids[]string// 不得出现的内容(如编造根因)}funcTestSummarize_QualityChecklist(t*testing.T){cases:=[]evalCase{{name:"保留关键事实",ticket:"三楼打印机离线,影响合同打印",stub:"三楼打印机离线,影响合同打印。",requires:[]string{"打印机","离线","合同"},forbids:[]string{"驱动损坏","硬件故障"},// 不得编造根因},{name:"信息缺失应标注",ticket:"打印机有问题",stub:"打印机存在问题,具体现象与位置未提供。",requires:[]string{"未提供"},forbids:[]string{"确认是"},},}for_,c:=rangecases{t.Run(c.name,func(t*testing.T){s:=&Summarizer{Model:&stubModel{reply:c.stub}}out,err:=s.Summarize(context.Background(),c.ticket)iferr!=nil{t.Fatal(err)}for_,want:=rangec.requires{if!strings.Contains(out,want){t.Errorf("缺少要素 %q,输出=%q",want,out)}}for_,bad:=rangec.forbids{ifstrings.Contains(out,bad){t.Errorf("出现了不该有的内容 %q,输出=%q",bad,out)}}})}}实机运行结果:
=== RUN TestSummarize_RejectsEmptyInput --- PASS: TestSummarize_RejectsEmptyInput (0.00s) === RUN TestSummarize_PropagatesModelError --- PASS: TestSummarize_PropagatesModelError (0.00s) === RUN TestSummarize_RejectsEmptyModelOutput --- PASS: TestSummarize_RejectsEmptyModelOutput (0.00s) === RUN TestSummarize_HappyPath --- PASS: TestSummarize_HappyPath (0.00s) === RUN TestSummarize_QualityChecklist === RUN TestSummarize_QualityChecklist/保留关键事实 === RUN TestSummarize_QualityChecklist/信息缺失应标注 --- PASS: TestSummarize_QualityChecklist (0.00s) --- PASS: TestSummarize_QualityChecklist/保留关键事实 (0.00s) --- PASS: TestSummarize_QualityChecklist/信息缺失应标注 (0.00s) PASS ok example.com/einobook/art31 0.637s注意测试全绿,但不代表应用“效果好”。这里验证的是逻辑正确性与清单符合度——真实效果必须在真实模型上跑评测集才知道。
这个区分很重要:测试通过 ≠ 质量达标。
五、该测什么、不该测什么
| 应该测 | 原因 |
|---|---|
| 输入校验与边界 | 确定性问题,最容易出 bug |
| 错误传播与分类 | 决定线上能否定位问题 |
| 状态与并发 | 编排、幂等、会话隔离 |
| 权限拒绝路径 | 安全逻辑必须验证(第 30 篇) |
| 降级与超时 | 失败路径容易被忽略(第 29 篇) |
| 检索召回 | RAG 的基础质量(第 15 篇) |
| 拒答正确性 | 防止编造 |
| 不该依赖测试断言 | 原因 |
|---|---|
| 模型输出的具体措辞 | 每次都可能不同 |
| “输出一定包含某句话” | 措辞会变,应检查要素而非原句 |
| 端到端延迟的精确值 | 受网络与负载影响,应设阈值区间 |
核心原则:测“约束”和“要素”,不测“措辞”。
六、回归:改动前后都要跑
AI 应用最大的风险是改一处、坏一处:
- 改了提示词,摘要变好了,检索问答却开始编造。
- 换了模型,效果好一点,但工具调用经常失败。
- 调了切分粒度,召回率上升,但答案开始跑题。
没有固定评测集,你无法察觉这些退化。
实践建议:
- 每次改动都跑同一套评测集。
- 记录基线:把当前指标存下来,作为对比基准。
- 覆盖负向用例:无资料、越权、超时、空输入。
- 特别关注拒答:宁可它说“不知道”,也不要它编。
七、成本与效果一起看
AI 应用的质量不能只看效果,还要看代价:
| 指标 | 说明 |
|---|---|
| 正确性 | 关键要素是否齐全、有无编造 |
| 拒答正确率 | 无依据时是否明确说明 |
| 引用正确率 | 标注的来源是否支持结论 |
| 延迟 | 首字节时间与总时长,分别统计 |
| 调用次数 | 多轮 Agent 的实际模型调用次数 |
| 费用 | 单次请求的 Token 成本 |
| 失败率 | 各节点的失败比例(第 28 篇) |
一个准确但每次要 30 秒、花 5 毛钱的回答,未必比一个 3 秒但略有瑕疵的回答更有价值。指标要一起看,由业务决定取舍。
第 28 篇的节点耗时统计、第 25 篇的MaxIterations、第 29 篇的重试次数,都是这些指标的来源。
八、常见问题
输出不固定,没法写断言
把断言从“句子相等”改成“要素包含/不包含”。这是本篇示例的做法。
测试要调真实模型,太慢太贵
用接口注入模拟实现测逻辑,真实模型只用于少量效果评测。两层分开跑。
改了提示词,怎么知道有没有变差
建评测集并记录基线,改动前后对比。没有基线就只能凭印象。
怎么测拒答
准备一批资料里没有的问题,检查系统是否明确说明“无依据”,而不是编造。
评测集要多大
几十条覆盖典型场景即可起步。关键是持续跑,而不是规模大。
怎么测并发与会话隔离
并发请求多个会话,验证历史不串、状态不共享。这是最容易出线上事故的地方。
十、小结
- AI 应用有两类验证:确定性代码用单元测试,模型效果用评测集,不要混为一谈。
- 依赖接口而非具体实现,用模拟模型让逻辑测试可复现、免网络、零成本。
- 断言“要素”(
requires/forbids),不要断言“措辞”。 - 必须有负向用例:无资料、越权、超时、空输入,重点验证拒答。
- 效果与成本一起看:延迟、调用次数、费用、失败率。
- 测试通过 ≠ 质量达标,真实效果必须在真实模型上评测。
- 建立基线并持续回归,否则无法发现退化。
下一篇
到这里,生产实践的各个单点都已经讲过了:如何对外提供接口(第 27 篇)、如何观测(第 28 篇)、如何保证可靠(第 29 篇)、如何守住安全边界(第 30 篇)、如何验证质量(本篇)。
下一篇是全书最后一篇——《32|综合实战:交付一个企业知识与工单助手》。我们会把这 31 篇的能力拼装成一个完整可运行的应用:路由、知识问答、工单查询、鉴权与拒答在同一个图里各就各位,作为全书的收束。
参考资料与版本说明
资料核对日期为2026 年 10 月 9 日:
- Eino 快速开始(ChatWithEino Quickstart)
- ChatModel 使用说明
- Eino 官方示例仓库
- Eino 主仓库
本文示例按Eino v0.9.21编写,是一份可直接执行的 Go 测试文件,已实机运行(go test6 个用例全部通过),正文输出为真实测试结果。