文章建议通过统一的 summarize 接口和模型适配器隔离供应商差异,并用配置切换模型。评测应在相同文档上比较质量、令牌、延迟和失败率,不能把相似的 Chat Completions JSON 当作语义兼容保证。
一个“一键切换”的 Node.js 摘要 API,应该在应用背后放置一层小而可靠、经过测试的模型适配器,并通过配置切换模型,而不是在业务逻辑中编写各种条件分支。比较候选模型时,应使用同一批文档,从摘要质量、输入和输出 token 数、延迟以及失败情况等维度进行评分。统一的 chat-completions 数据结构确实很有用,但它并不能证明每个模型的行为都完全相同。
我把“兼容 OpenAI、Claude 和 Gemini”视为一项必须由 eval 套件验证的声明,而不是一套完备的标准。它能提供的可靠承诺其实很有限:应用可以发送一组熟悉的 role/content 消息,并收到熟悉的 choice/message 结果。真正危险的假设是,JSON 格式相同就意味着语义也相同。不同模型对长度要求的理解可能不同,支持的上下文上限不同,计算 token 的方式不同,甚至可能省略某个可选字段——而应用代码中的某个人却不小心把它当成了必填字段。
对于 Node.js 服务,我会让 HTTP handler 尽可能朴素。它只负责验证文档、分配 request ID、调用内部的 summarize() 接口,然后返回结果。provider 凭据、请求转换、超时策略、usage 归一化以及模型标识符,都由适配器负责。虽然线上服务使用 Node.js,但离线 eval harness 我会用 Python 编写,因为我的 notebook 和评分脚本本来就在 Python 生态中。两者之间的边界与语言无关:传入一个请求对象,返回一个归一化结果。
“One key”可能对应两种不同的架构。在直连架构中,应用对外只有一个逻辑上的凭据接口,但 secret manager 仍然保存着各个上游 provider 的独立凭据。在 gateway 架构中,应用可能真的只持有一个 gateway 凭据,由 gateway 负责上游身份认证。这两种设计在安全、计费和故障边界上都不相同——不要在架构图里把它们混为一谈。
关键点很简单:当你需要 provider 特有的控制能力、最短的数据传输路径,或者独立的支持升级渠道时,就应继续使用直连 provider 适配器。如果相关政策不允许增加额外的数据处理跳点,那么 gateway 并不适用。对于电子受保护健康信息,在发送任何样本文档之前,我会先梳理每一个数据跳点、日志落点、保留规则和访问控制,并将其映射到 45 CFR Part 164 中适用的安全保障要求。
我的第一版 notebook 只使用了一个 prompt、五份人工挑选的文档,再加上肉眼抽查。它运行得很快,却几乎无法说明真实生产数据的分布情况,因为这五份文档都很短、很干净,而且使用相同的内部写作风格。真正值得信赖的版本,会在选择候选模型之前冻结语料库,其中包括:简短的工单、很长的邮件线程、复制粘贴的表格、空输入、重复的样板文字,以及接近最大预算的文档。在语料库进入开发者笔记本电脑之前,我会先对敏感字段进行脱敏,或者使用合成数据替代。
接下来,我会根据结果定义契约。一个成功的摘要必须忠实于原文,包含所有必需实体,不得出现无依据的陈述,符合长度预算,并返回一条可用于计量的 usage 记录。在忠实度之前,我不会先评估文风。润色得再漂亮的幻觉,依然是一次失败的运行。
下面是我的 Python harness 中最核心的部分。provider 调用通过注入传入,因此同一组测试用例可以覆盖直连适配器、gateway 或本地 fake,而不必硬编码一条未经验证的路由。它会记录成本比较真正需要的数据,而不是假装某个模型的 tokenizer 对其他所有模型也同样权威。
from dataclasses import dataclass
from time import perf_counter
from typing import Callable
@dataclass(frozen=True)
class Usage:
input_tokens: int
output_tokens: int
@dataclass(frozen=True)
class ModelResult:
text: str
usage: Usage
@dataclass(frozen=True)
class EvalRow:
model: str
case_id: str
latency_ms: int
input_tokens: int
output_tokens: int
faithful: bool
def run_case(
call_model: Callable[[str, str], ModelResult],
model: str,
case_id: str,
document: str,
grade_faithfulness: Callable[[str, str], bool],
) -> EvalRow:
started = perf_counter()
result = call_model(model, document)
elapsed_ms = round((perf_counter() - started) * 1000)
return EvalRow(
model=model,
case_id=case_id,
latency_ms=elapsed_ms,
input_tokens=result.usage.input_tokens,
output_tokens=result.usage.output_tokens,
faithful=grade_faithfulness(document, result.text),
)
评分器应该结合确定性检查和盲审式人工评估。我会使用精确检查来识别禁止出现的开场白、缺失的必填字段以及不符合要求的输出长度。对于事实一致性,我会人工抽查失败案例和接近及格线的案例,因为对于某些包含否定表达的陈述,我也无法确定自动化 judge 为什么会给出不同判断。如果文档具有高度模板化的结构,你的实际效果可能有所不同,但无论如何,都应该公开评分规则,并在一次比较过程中保持不变。否则,所谓的模型切换最终只会变成:针对最后运行的那个候选模型不断调整 prompt。
根据价格表计算费用应该是最后一步,而不是第一步。对于每条 eval 记录,都要采集 provider 报告的输入 token、输出 token、尝试次数、延迟和完成状态。然后,将这些测量数据与应用之外维护的、带版本号的费率表关联起来。这样,团队无需部署摘要代码就能更新费率,同时还能保留当初做出决策时所依据的历史假设。我会报告每份成功处理文档的成本中位数和尾部成本,以及每份通过评估的摘要成本。只看每次调用的原始成本,会奖励那些虽然便宜、结果却不可用的模型。
我是通过一次令人不快的回填任务认识到这一点的。我根据文档长度中位数估算出输入量为 800 万 token,但 usage meter 最终显示实际用了 2160 万 token。转发邮件线程多次引用了同一段历史内容,而且在 inference 之前,有一个预处理步骤把紧凑的附件元数据扩展成了冗长的自然语言。中位数本身是准确的,但对于长尾数据毫无意义。
一开始,我怀疑是请求被重复提交,于是沿着队列追踪 request ID,并对比源文档的 hash;结果发现这些调用都是唯一的。数据膨胀发生得更早,位于数据摄取和 prompt builder 之间,而我那份整洁的 notebook 样本从未覆盖过嵌套回复或附件描述。这个区别改变了解决方案:对请求去重根本无法发现问题,而检查转换后的 payload,则能立刻看出异常膨胀。那张账单到来之后,我开始在批准任何批处理任务之前,按照文档分位数测量总字符数和 provider 报告的 token 数量,并把内容扩展阶段单独暴露为一个 trace span。
做成本规划时,应根据实际测量的 token 数和候选模型当前的输入、输出费率计算成本,但不要在博客文章或源代码文件中写死一个所谓的“永远赢家”。费率会变化,模型版本会迁移,prompt caching 规则也可能改变实际输入成本。更重要的是,一个价格更低的候选模型,可能因为重试次数更多、输出更长,或者 eval 通过率更低而最终落败。我至少会比较三个数据切片:普通文档、允许范围内最长的文档,以及会触发可重试客户端条件的文档。我还会在调用之前限制输入大小,并在上游拒绝空 payload 或明显重复的 payload。
对待 prompt 成本,也应该像对待代码性能一样严谨。每次运行都要保存 prompt 模板的 hash、模型标识符、适配器版本和语料库版本。其中任何一项发生变化,都应该视为一次新的实验。如果缺少这条追溯链,dashboard 可能会显示成本有所下降,但真正的原因只是 prompt 变短了,团队却把功劳归给了模型切换。
从 notebook 到生产环境的差距,通常会暴露在模型调用周围。应明确设置连接和读取 deadline;只对适配器判定为安全的情况执行有上限的重试;当上游契约确实支持时,再附加 idempotency 机制。绝不能假设没有收到响应就意味着这次请求没有消耗 token。应针对每个上游边界分别设置并发限制,避免某个缓慢模型耗尽 Node.js worker pool。对于调用方不需要立即获得结果的批量摘要任务,则应该使用队列。
我的归一化结果包含 text、model、input_tokens、output_tokens、latency_ms,以及一个稳定的错误类别。它不会把某个 provider 的完整响应泄漏到业务逻辑中。如果政策允许,我仍会在受限的诊断系统中保留经过清理的原始响应,因为仅凭六个字段,很难调查归一化过程中的 bug。默认情况下,日志必须排除源文档和生成的摘要。对于运维而言,request ID、内容 hash、字节数、模型、耗时、token usage 和结果状态通常已经足够。
模型切换应该采用分阶段部署,而不是通过修改环境变量一次性切换整个集群。先在冻结的语料库上回放测试;在用户同意且政策允许的情况下,对少量生产样本执行 shadow 测试;然后再进行 canary 放量,并设置与 eval 失败率和延迟绑定的回滚阈值。应按照文档类型观察摘要接受率,而不是只看全局平均值。一个模型可能改善会议记录的摘要效果,同时降低客服工单的质量,从汇总数据看却似乎没有变化。
兼容性本身也有维护成本。应使用经过记录和脱敏的 fixture 固定适配器测试,并验证必需的请求字段、可选的响应字段、使用 streaming 时的行为,以及 usage 统计是否准确。LangChain 的 ChatOpenAI integration 是围绕 chat-model 接口构建抽象层的一个例子,但抽象永远无法替代对底层实际行为的测试。如果应用将来需要某个 provider 独有的功能,就让那次调用使用专门的适配器,不要不断拉伸共享契约的边界,直到它的名称已经无法表达其真实含义。
在复制这套架构之前,请先测量你的语料库大小分布、摘要通过率、p50 和 p95 延迟、每份成功摘要消耗的 token 数、重试频率,以及需要 provider 特有行为的调用比例。正是这些数字决定了“一把 key、一个接口”究竟能减少工作,还是仅仅把工作转移到了别处。
参考资料:LangChain ChatOpenAI integration 文档。
参考资料:45 CFR Part 164,安全与隐私。
如果需要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。