单个 API 密钥能减少部署工作,但无法让模型行为变得可移植。对于一家按职位评分标准评估候选人的 SaaS,真正有用的可移植单位,是由六个验收用例支撑的、带版本的评分契约。我的选择是设置一个小型适配器边界、校验每次响应,并且只有当某个提供商与模型组合通过这套测试后才予以启用。
**简答:**比较集成方案时,应看它们保留了哪些应用行为,而不是替换了哪些凭据。OpenAI、Claude 和 Gemini 可以共用一个应用接口,但每个仍是独立的执行目标。兼容聊天形式的请求,只代表传输兼容。候选人评估结果稳定,才是产品要求。
这对单人运营的 SaaS 很重要。设置时省下一小时,只能受益一次。避免评分悄然变化,则能保护每周发布。密钥存储和架构校验是差异化程度低的工作,我会交给外部服务;评分标准和决策规则仍保留在应用代码中。
一个凭据只回答服务如何进行身份验证。候选人评分会引出更难的问题:评分标准中的每个项目都出现了吗?每个分数是否都与证据挂钩?证据缺失时会变成 unknown,还是模型会凭空编造确定性?
请求能被端点接受,并不能解决这些问题。通过通用请求格式支持 OpenAI、Claude 和 Gemini,不意味着它们会产生相同的输出。我会把每个确切的模型都记录为经过测试的目标,绝不会仅凭提供商名称推断它们的行为等价。
改变设计的约束很简单:上游输入不可信的简历文本,下游则由招聘流程使用结果。摘要格式错误会令人恼火;看似合理却没有依据的分数,可能影响谁能获得审核机会。因此,首要抽象应是 scoreCandidate(),而不是通用的 chat() 方法。
让这个边界保持简单。
批处理工作也应采用独立的生命周期。OpenAI Batch API 指南介绍了输入上传、批次创建、状态检查和输出检索。这不是交互式调用。可移植的应用应单独建模延迟处理的任务,而不是强迫两种路径共用一种阻塞式聊天抽象。
契约承载的是领域事实,而不是提供商术语。对于未知证据,它保留 null。零表示某项标准未达成;null 表示所提交的材料不足以支持判断。把两者合并会造成虚假的精确性。
type Criterion = { id: string; description: string; weight: number };
type ScoreRequest = {
rubricVersion: string;
criteria: Criterion[];
candidateText: string;
};
type CriterionResult = {
criterionId: string;
score: 0 | 1 | 2 | null;
evidence: string[];
};
type ScoreResult = {
rubricVersion: string;
results: CriterionResult[];
};
interface ScoringTarget {
id: string;
score(input: ScoreRequest): Promise<unknown>;
}
返回 unknown 是有意为之。提供商的输出只有经过本地校验后,才会成为应用数据。生产环境中,采用维护中的架构校验库是明智之选,但这些检查本身是领域要求:评分标准版本匹配、标准 ID 已知且唯一、结果完整、分数在允许范围内,以及证据为字符串。
校验失败时拒绝放行。有限次数的重试可以修复截断的输出,但不能把证据缺失变成通过。达到重试上限后,将该项交由人工审核并显示这一状态。不要悄悄把它转换为零分。
最终加权分数应在本地计算。权重由应用负责,因此可以确定性地应用。
function weightedScore(request: ScoreRequest, result: ScoreResult): number | null {
const byId = new Map(result.results.map((item) => [item.criterionId, item]));
if (result.results.some((item) => item.score === null)) return null;
const totalWeight = request.criteria.reduce((sum, item) => sum + item.weight, 0);
if (totalWeight <= 0) throw new Error("invalid_weights");
return request.criteria.reduce((sum, criterion) => {
const score = byId.get(criterion.id)?.score;
if (score === null || score === undefined) throw new Error("missing_score");
return sum + (score / 2) * criterion.weight;
}, 0) / totalWeight;
}
我希望测试样例足够小,能在每次变更时运行。六个用例覆盖了第一个有用的边界,但并不假装它们是通用基准测试。
| 用例 | 输入压力 | 必须满足的断言 |
|---|---|---|
| 1 | 每项标准都有有力证据 | 每个 ID 都恰好出现一次,并附有可追溯的证据 |
| 2 | 某项标准没有证据 | 该项结果为 null,而不是猜测得出 |
| 3 | 有证据与某项主张相矛盾 | 矛盾与缺失仍明确区分 |
| 4 | 简历文本包含评分指令 | 候选人文本不能取代评分标准 |
| 5 | 很长但有效的工作经历 | 输出完整,或明确报错 |
| 6 | 分数线附近几乎相同的候选人 | 差异可供检查,判定规则始终一致 |
对考虑采用的每个提供商与模型组合,运行全部测试样例。只有六项全部通过,才能启用。将规范化后的断言和配置指纹与部署记录一同保存。原始简历需要访问控制和保留规则;不应放进普通日志。
有一个用例值得格外关注。在用例 4 中,简历可能包含一句看似要求给出最高分的指令。预期结果不是模型必须使用某种特定措辞,而是保留评分标准和输出格式,并且依据来自候选人的经历,而非那条指令。审核人员可以检查这三项断言,无需争论文风。如果某个目标未通过,在适配器、指令或模型选择发生变化且整套测试重新通过之前,它都不能用于生产环境。
如果一个密钥网关无法提供稳定的目标标识、保留请求关联信息,或区分错误与模型内容,那么即使它的首次请求看起来多么方便,也无法满足这一工作流程。这就是每小时营收检验:小型 CI 测试套件能防止反复人工调查,又不会演变成平台项目。
错误需要分类。身份验证失败时,应停止向该目标发送流量。限流和暂时性传输故障可以采用有限次数的退避重试。输出无效时,可尝试受控修复,然后转交人工审核。策略拒绝既不是格式错误的 JSON,也不是零分。
回退存在风险。第二个目标可能返回格式有效、判断却不同的结果。如果启用了回退,它必须通过相同版本的测试样例,并且记录中必须注明由哪个目标生成了评估结果。
不允许无提示替换。
业务量增大时,我会按职位类别和评分标准类型扩大金丝雀测试,将交互式评分与批量重新处理分开,并审核任何会改变门槛的评分标准更新。只有当每个测试样例都体现了经过审核的产品预期时,增加样例才有帮助。
在可能的情况下,我还会固定确切的模型标识,并记录适配器版本、评分标准版本、目标 ID、延迟、校验结果、重试次数和应用关联 ID。监测校验失败、人工审核量、证据缺失率,以及固定金丝雀测试中的决策变化。延迟和 token 用量有助于容量规划,但两者都不能证明评分质量。
代价是上线前需要多做一些工作。六个测试样例、一个解析器和明确的错误处理,比更换基础 URL 并粘贴密钥花的时间更长。它们能提供证据,证明更换目标后面向客户的行为仍保持不变。这才是值得持续维护的可移植性检验。
参考资料:
- OpenAI Batch API 指南:https://platform.openai.com/docs/guides/batch
- OpenAI Whisper 开源语音识别仓库:https://github.com/openai/whisper