☰
31|测试与评估:AI 应用怎样才算“可用”? - eino教程
2026/10/31 1:57:48 网站建设 项目流程

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 应用最大的风险是改一处、坏一处:

  • 改了提示词,摘要变好了,检索问答却开始编造。
  • 换了模型,效果好一点,但工具调用经常失败。
  • 调了切分粒度,召回率上升,但答案开始跑题。

没有固定评测集,你无法察觉这些退化。

实践建议:

  1. 每次改动都跑同一套评测集。
  2. 记录基线:把当前指标存下来,作为对比基准。
  3. 覆盖负向用例:无资料、越权、超时、空输入。
  4. 特别关注拒答:宁可它说“不知道”,也不要它编。

七、成本与效果一起看

AI 应用的质量不能只看效果,还要看代价:

指标说明
正确性关键要素是否齐全、有无编造
拒答正确率无依据时是否明确说明
引用正确率标注的来源是否支持结论
延迟首字节时间与总时长,分别统计
调用次数多轮 Agent 的实际模型调用次数
费用单次请求的 Token 成本
失败率各节点的失败比例(第 28 篇)

一个准确但每次要 30 秒、花 5 毛钱的回答,未必比一个 3 秒但略有瑕疵的回答更有价值。指标要一起看,由业务决定取舍。

第 28 篇的节点耗时统计、第 25 篇的MaxIterations、第 29 篇的重试次数,都是这些指标的来源。


八、常见问题

输出不固定,没法写断言
把断言从“句子相等”改成“要素包含/不包含”。这是本篇示例的做法。

测试要调真实模型,太慢太贵
用接口注入模拟实现测逻辑,真实模型只用于少量效果评测。两层分开跑。

改了提示词,怎么知道有没有变差
建评测集并记录基线,改动前后对比。没有基线就只能凭印象。

怎么测拒答
准备一批资料里没有的问题,检查系统是否明确说明“无依据”,而不是编造。

评测集要多大
几十条覆盖典型场景即可起步。关键是持续跑,而不是规模大。

怎么测并发与会话隔离
并发请求多个会话,验证历史不串、状态不共享。这是最容易出线上事故的地方。


十、小结

  • AI 应用有两类验证:确定性代码用单元测试,模型效果用评测集,不要混为一谈。
  • 依赖接口而非具体实现,用模拟模型让逻辑测试可复现、免网络、零成本。
  • 断言“要素”(requires/forbids),不要断言“措辞”。
  • 必须有负向用例:无资料、越权、超时、空输入,重点验证拒答。
  • 效果与成本一起看:延迟、调用次数、费用、失败率。
  • 测试通过 ≠ 质量达标,真实效果必须在真实模型上评测。
  • 建立基线并持续回归,否则无法发现退化。

下一篇

到这里,生产实践的各个单点都已经讲过了:如何对外提供接口(第 27 篇)、如何观测(第 28 篇)、如何保证可靠(第 29 篇)、如何守住安全边界(第 30 篇)、如何验证质量(本篇)。

下一篇是全书最后一篇——《32|综合实战:交付一个企业知识与工单助手》。我们会把这 31 篇的能力拼装成一个完整可运行的应用:路由、知识问答、工单查询、鉴权与拒答在同一个图里各就各位,作为全书的收束。


参考资料与版本说明

资料核对日期为2026 年 10 月 9 日:

  1. Eino 快速开始(ChatWithEino Quickstart)
  2. ChatModel 使用说明
  3. Eino 官方示例仓库
  4. Eino 主仓库

本文示例按Eino v0.9.21编写,是一份可直接执行的 Go 测试文件,已实机运行(go test6 个用例全部通过),正文输出为真实测试结果。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询