前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
返回 AI 情报前线
All News · 全部资讯9708
  • 阿里Qwen全球下载量破30亿:开源模型生态超越Meta、谷歌
  • 共享LLM客户端的线程安全陷阱:per-request header污染实战
  • AI生成测试的七步审查法:如何避免假阳性测试混入代码库
  • LLM对话中途系统Prompt失效的根因:payload结构被篡改
  • 程序员Prompt进阶:从泛泛提问到精准获取AI辅助
  • Agent架构必测:父Agent使用子Agent输出前的三重验证
  • 结构化输出严格模式的实测方法:三个可测试的边界
  • LLM流式响应中断的识别与处理:截断与不完整需区分对待
  • SSE断线重连:协议机制与OpenAI简历流详解
  • Simon Willison开源CORS测试工具:本地跑大模型调API
  • AI 返回空内容时的三种典型报错与处理策略
  • 独立于模型调用测试 Few-Shot 示例筛选逻辑
  • AI 降级兜底策略的 prompt 会腐化:识别与修复
  • 验证 AI 降级链路是否真正按配置顺序切换 Provider
  • 测试两个 Provider 同时宕机时的 AI 服务降级行为
  • 模型返回非枚举值的三种机制与测试策略
  • Embedding向量重复调用结果不稳定的技术陷阱
  • Agent工具调用失败应单独处理而非混入消息队列
  • 从工单正文提取结构化字段的实战经验
  • Trivy 漏洞而非恶意 LiteLLM 包导致 2500+ 组织被入侵
  • 不用 Mock 库-stub LLM 客户端的实用方案
  • 按失败模式而非功能组织 Prompt 回归测试套件
  • 修复Claude Code流式输出延迟:自建路由中间件实战
  • Claude Code流式响应卡死的诊断:流控与队列问题
  • Cloudflare AI功能异常事件的技术根因分析
  • SageMaker容器部署核心:/ping与/invocations接口避坑指南
  • SageMaker多模型端点:S3前缀按需加载机制与请求等待代价
  • SageMaker ModelError排查:HTTP 424错误根因定位方法
  • SageMaker实例选型:CPU/GPU利用率与调用延迟的监控指标解读
  • SageMaker账单超预期根因:实例时长、存储、数据传输三部分独立计费
  • SageMaker端点自动扩缩容:Application Auto Scaling配置三步法
  • SageMaker异步推理:突破60秒同步调用限制与6MB_payload上限
  • Yadda 3.0:BDD在AI Agent时代的新设计
  • 测试录制还是手写Fixture?一个判断规则终结争议
  • 负载测试中模拟真实Prompt长度分布的必要性
  • 生产环境升级SDK前必读:如何定位真实运行版本并过滤关键变更
  • flaky测试的隔离策略:保留但不阻断CI
  • Skill加载正常却从不触发?问题在描述写法
  • LLM量化失败的根因:离群特征为何非处理不可
  • GPTQ/AWQ量化中Group Size的精确含义与计算
  • 量化粒度全解析:per-tensor/channel/group的权衡
  • 自动化免费模型接口前的四项必测指标
  • WordPress RAG系统的权限劫持漏洞
  • LLM系统护栏设计:从输入过滤到输出控制
  • LLM 输出「看着不错」不是指标:构建可衡量评测体系
  • 思维链原理:为何"分步思考"让LLM推理更准
  • 温度与top-p:LLM随机性控制参数详解
  • Agentic Coding变革:从代码补全到自主任务执行
  • 向量数据库原理:语义搜索如何实现大规模高效检索
  • LLM 上下文窗口的工作原理与实用边界
  • LangChain 从 Runnables 到生产级 AI Agent 完整指南
  • 已加载 51 / 9708
8.0
热点
AI SCORE
编程提效2026-08-15 22:30

不用 Mock 库-stub LLM 客户端的实用方案

dev.to · AI#测试#LLM#Mock
Editor brief · 编辑速览

框架 Mock 适合单次调用,但 LLM 客户端状态ful、多轮调用、token 累计,自定义 Fake 类更可读。关键前提是依赖注入接口而非直接导入客户端。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

Mock 框架非常擅长替代只调用一次的方法。而 LLM 客户端是一个你反复调用的依赖,且调用之间存在状态——而这恰恰是手写 fake 开始比一堆 matcher 配置更具可读性的地方。

为什么手写 Fake 在这个依赖上胜出

这个论点不是意识形态层面的。问题在于,你需要模拟的行为是有状态的,而有状态正是框架 mock 表达最差的那一类。

思考一个真实测试需要什么:第一次调用失败、第二次成功,从而触发重试路径。返回一次工具调用,然后接受工具结果并返回最终答案,从而运行一个双轮 agent 循环。只有当请求的 maxTokens 低于某个数值时才返回截断的答案。在整个会话中累积 token 用量,使得第四次调用时预算检查触发。上面每一种情况,在一个类里只需几行代码,而用框架则需要一串链式 matcher 配置——而且那个类是六个月后你依然能读懂的那个。

前提条件是一个"接缝"(seam)——一个你拥有的接口,通过注入而非导入来使用。如果你还没有这样一个接口,这是"为 LLM 客户端做依赖注入"这个主题要做的事,而且要先行,因为没有它就没有地方放置下面要讲的对象。

有一种情况框架会毫无争议地胜出,说清楚它可以防止这篇文章变成教条:只调用一次、调用之间无状态、且接口既不为你所有也不能修改的依赖。单独一个 vi.fn() 返回固定值比一个类更短,而且表达的意思同样多。本文论述的具体场景是模型客户端,它是有状态的、在一次逻辑操作中反复调用、且位于你自己写的接口之后。

// test/doubles/fake-chat-model.ts
import type { ChatModel, Completion } from "../../src/ports";

type Scripted = Completion | Error;

export class FakeChatModel implements ChatModel {
  /** Every request this fake was asked to complete, in order. */
  readonly calls: Array<Parameters<ChatModel["complete"]>[0]> = [];

  private script: Scripted[] = [];
  private fallback: Scripted | undefined;

  /** Queue one outcome for the next call. Chainable. */
  reply(next: Partial<Completion> & { text: string }): this {
    this.script.push({
      finishReason: "stop",
      usage: { inputTokens: 100, outputTokens: 20 },
      ...next,
    });
    return this;
  }

  /** Queue a rejection for the next call. */
  fail(error: Error): this {
    this.script.push(error);
    return this;
  }

  /** Answer every call after the script runs out, instead of throwing. */
  thenAlways(next: Scripted): this {
    this.fallback = next;
    return this;
  }

  async complete(req: Parameters<ChatModel["complete"]>[0]): Promise<Completion> {
    this.calls.push(req);
    const next = this.script.shift() ?? this.fallback;
    if (!next) {
      throw new Error(
        `FakeChatModel: unexpected call #${this.calls.length}. ` +
          `Queue another reply() or fail(), or set thenAlways().`,
      );
    }
    if (next instanceof Error) throw next;
    return next;
  }
}

其中有三个决策值得点名。默认结果是抛出异常,而非预设的成功响应,因为对有偿依赖的意外调用是一个 bug,而一个默默吸收它的 fake 恰恰会掩盖你真正在测试什么。错误信息包含调用序号,因为"意外调用"没有ordinal就什么都告诉不了你。而 calls 是一个公开的普通数组而非一组查询方法,所以断言就是普通的 JavaScript,不需要来自任何库的词汇。

使用方式:三个测试,无框架

import { describe, expect, it } from "vitest";
import { FakeChatModel } from "./doubles/fake-chat-model";
import { Triage } from "../src/triage";

describe("Triage", () => {
  it("sends the ticket and normalises the answer", async () => {
    const model = new FakeChatModel().reply({ text: "  Billing\n" });

    const label = await new Triage(model).classify("card declined");

    expect(label).toBe("billing");
    expect(model.calls).toHaveLength(1);
    expect(model.calls[0].user).toBe("card declined");
    expect(model.calls[0].maxTokens).toBe(8);
  });

  it("rejects a truncated classification instead of using half a word", async () => {
    const model = new FakeChatModel().reply({ text: "bil", finishReason: "length" });

    await expect(new Triage(model).classify("…")).rejects.toThrow(/truncated/);
  });

  it("does not call the model twice for the same ticket in one request", async () => {
    const model = new FakeChatModel().reply({ text: "billing" }).thenAlways(new Error("second call"));
    const triage = new Triage(model);

    await triage.classify("card declined");
    await triage.classify("card declined");

    expect(model.calls).toHaveLength(1);   // fails loudly if the cache regressed
  });
});

再读一遍第三个测试,因为它是离开了这个 double 就写不出来的那个。它断言的是一个 Absence——即同一次请求中,第二次逻辑请求没有变成对付费 API 的第二次调用——而 Absence 只有在有东西在计数时才是可观测的。thenAlways(new Error(...)) 把"缓存退化了"从一个静默的账单翻倍变成了一个具名的测试失败,并报告哪一次调用是意外的那次。

只有断言词汇来自测试运行器。把 vitest 换成 Node 内置的测试运行器,double 纹丝不动——如果你的代码库跨了好几个运行器,这是实实在在的好处。

你放弃的四件事

参数 matcher。 Double 内部没有 expect.objectContaining 的等价物,所以一个只关心请求中某个字段的测试仍然要把整个对象从 calls 里取出来再对该字段做断言。实践中这没什么大不了,甚至可以说更清晰,但确实要多打一些字。

自动验证。 严格的 mock 在配置的期望从未被满足时会直接让测试失败。你的 fake 不会注意到排了队却没人消费的 reply。在关键的地方加一个辅助方法断言 script 为空——就是一个在 script.length > 0 时抛出的单一方法。

对你没有为其设计的东西进行 spy。 如果你事后想知道某次调用是发生在某个无关副作用之前还是之后,框架 mock 免费记录时间戳和顺序;你的 fake 只记录你告诉它记录的。

失败信息。 框架断言失败时会打印预期参数和实际参数的 diff。手动比较 calls 的条目只会得到你的运行器为两个对象打印的内容——对于一个大型请求体来说就是一大堵文字。不断言整个对象而是断言具体字段,这种情况大多会消失。

Partial double。 用框架保留九个真实方法、伪造一个轻而易举,而这里需要手写委托。如果接口很小这种情况很少发生,而这本身也是保持接口小巧的理由之一。

这些都不是致命的,对于这个特定依赖来说这个权衡通常是值得的。对于一个有三十个方法的大型遗留接口就不会是这样。

防止它偏离轨道

任何手写 double 都存在一个真正的风险:它不再像真实的东西,你的测试套件开始验证的是一个虚构物。三个廉价的防护措施:

声明 implements ChatModel 并在 CI 中检查类型。新方法或变更的签名就会在 double 中导致编译失败——这是发现问题最早的时机。

从接口派生参数类型,就像上面 Parameters<ChatModel["complete"]>[0] 那样,而不是重新声明它。重新声明的类型是一份副本,会悄无声息地变得过时。

从你的解析器测试使用的同一个 fixture 文件构建 fake 的预设响应,并从真实记录的流量中捕获这些 fixture 而不是手动输入。一个输出靠手捏造的 fake 测试的是你的想象力;输出被记录下来的 fake 测试的是你的代码。

最后一步的后半部分比 double 本身更重要。至少保留一个让适配器针对真实记录的响应运行的测试,这样 fake 回放的 fixture 已知是来自 provider 而不是来自代码审查。

一个习惯让整个方法更持久:把 double 放在测试树里,而不是 src 中。随生产包一起发货的 fake 会引诱人用它来做 feature flag——一个"试运行模式",悄悄返回预设答案——到了那个地步测试 double 就在决定你的用户看到什么。测试 double 属于 test/doubles/,只导出给测试,不导出给任何其他东西。


Testing Retry Logic Around an LLM API Call

Testing an AI Feature Without Calling the Model

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
Trivy 漏洞而非恶意 LiteLLM 包导致 2500+ 组织被入侵
下一篇
按失败模式而非功能组织 Prompt 回归测试套件