动态 Few-Shot 选择本质是排序问题,抽象为 query+示例库→有序 ID 列表的函数。预计算向量、返回 ID 而非对象,可实现零模型调用的单元测试。
动态少样本选择是一个位于语言模型之前的排序问题,而排序问题就是普通的软件工程。这个层面的几乎所有 bug——顺序错误、重复、token 预算超支、答案泄露到自身示例中——都无需任何模型调用即可发现。
重构优先。选择必须是一个函数,输入一个查询和一个示例库,输出一个有序的示例 ID 列表。不是渲染后的 prompt,更不是 completion。如果你的代码把查询、搜索、格式化示例和调用模型写在同一个函数里,以下所有断言都无法在不 mock 模型的情况下写出,而 mock 模型的测试实际上是在测试 mock 本身。
返回 ID 而非示例对象。这让断言可读——expect(ids).toEqual(["ex-14", "ex-3", "ex-91"]) 是可以从失败信息中调试的,而三个示例体的 diff 是一堵墙。这也将测试与示例本身的附带修改解耦了。
选择器需要 embedding,在单元测试中调用 embedding API 会让它变慢、昂贵、依赖网络,且在提供商模型更新后不可复现。预先计算示例库和一组固定测试查询的向量,提交为 fixture,让测试注入它们。
有两点值得明确说明。测试现在验证的是你的排序逻辑而非 embedding 质量,这是正确的——这两个是不同关注点,有着不同的失败模式和不同的修复方式。而且 fixture 相对于实时 embedding 模型会过期,这对这个测试来说没问题,这正是 embedding 漂移有其独立关注点的原因。
保持 fixture 小而精心设计,而非采样。一个包含十二个示例的库,其中的相似关系是你刻意选择的——两个近似重复、一个精确匹配、一个对抗性 near-miss、四个无关——可以产生你能解释预期输出的测试。而一个包含 2000 个真实 embedding 的库,其预期值是你从实现中抄来的,这只断言代码仍然做了它之前做的事。
import { describe, expect, it } from "vitest";
import { selectExamples } from "../src/fewshot";
import bank from "./fixtures/example-bank.json"; // {id, text, vector, label}
import queries from "./fixtures/queries.json"; // {id, text, vector}
const select = (queryId: string, opts = {}) =>
selectExamples({ query: queries[queryId], bank, k: 3, ...opts }).map((e) => e.id);
describe("few-shot selection", () => {
it("ranks the exact match first and the adversarial near-miss last", () => {
expect(select("refund-over-limit")).toEqual(["ex-refund-exact", "ex-refund-similar", "ex-refund-partial"]);
});
});
基数(Cardinality)。当库中至少有 k 个候选超过阈值时,正好返回 k 个示例,否则返回更少——不是填充,不是异常。明确测试欠载情况;这是新租户库为空时会发生的那种情况。
唯一性(Uniqueness)。没有 ID 出现两次。重复来自包含近似相同条目的库,也来自两次检索合并,会导致示例重复浪费预算,同时让模型偏向一种模式。
确定性顺序,包括相同情况。两个得分相同的示例必须以定义的顺序返回——比如以 ID 作为次级排序。没有明确的平局打破规则,顺序就依赖于底层排序的稳定性和插入顺序,你最终会追着一个 heisenbug 跑遍一个以渲染 prompt 为 key 的 prompt 缓存。
Token 预算。组合后的示例符合给定的预算。用计数预算而非估算预算做断言,并测试单个示例超过整个预算的情况:选择器应该跳过它取下一个,而不是返回过大的 prompt 或什么都不返回。
多样性,如果你声称支持的话。如果选择器应该避免近似重复,构建一个库,其中按相似度排名前三的示例是近似相同的,然后断言第三个槽给了别的东西。没有这类测试的多样性功能是一个悄然失效的功能。
标签平衡,如果你声称支持的话。对于分类少样本,当库可以提供其他标签时,断言返回的集合不全是同一标签。全阳性示例集合会让模型偏向阳性类别,这是一个真正常见的 bug。
如果你的示例库和你的评估集来自相同的 historical data,选择器最终会将 eval case 本身检索为少样本示例。然后模型被展示答案并被问问题,你的分数上升了,流程中没有任何错误——只是结论错了。
直接断言排除:给定一个与库条目字节完全相同的查询,那个条目不能被选中。然后断言更难的版本,即近似重复排除——相同 case 只是客户名不同或条款顺序不同。按 ID 做精确匹配排除容易但不足,因为支持和工单数据中的真实重复很少是字节完全相同的。
it("never returns the query's own case, exact or near-duplicate", () => {
expect(select("ex-refund-exact-as-query")).not.toContain("ex-refund-exact");
expect(select("ex-refund-exact-renamed")).not.toContain("ex-refund-exact");
});
针对你的 eval harness 的配置运行这个测试,而不仅仅是生产配置。两者往往在关键方面不同,因为 eval harness 是有人扩大了库以覆盖更多 case 的地方。
如果一个选择器在查询增加一个逗号时返回完全不同的集合,这会让你的整个应用看起来是非确定性的,而模型会被怪罪。测试它:拿一个查询,产生三个trivial变体——添加标点、改了一个专有名词、相同含义的条款重排序——然后断言与原始选择的重叠率高于你写下的阈值。
const overlap = (a: string[], b: string[]) =>
a.filter((id) => b.includes(id)).length / a.length;
it.each(["punctuation", "renamed-entity", "clause-order"])(
"keeps at least two of three examples under a %s perturbation",
(variant) => {
expect(overlap(select("refund-over-limit"), select(`refund-over-limit--${variant}`)))
.toBeGreaterThanOrEqual(2 / 3);
},
);
阈值是一个产品决策而非事实,写在测试里才能让其可审查。对于 k=3 的情况,三分之二是一个合理的默认值;这个值远不如测试存在本身重要,因为它真正防止的是有人换了相似度度量或不同地规范化向量,却没有注意到选择现在在每次按键时都在 churn。