详细阐述如何在 SaaS 中构建 token 计费的摘要流水线:先计数再分块,各 chunk 独立摘要,最后归约合并,并给出带成本估算的可观测方案,强调结构化输出的版本化治理。
简短回答:将 SaaS 功能构建为基于 token 计量的 map/reduce 流水线:发送前先计数、拆分长文本、对每个分块做摘要、reduce 这些摘要,并在接受任务前预估成本。
对于读取供应商发票的健康科技工作流来说,结构化输出的正确性是决定性约束。一条廉价响应如果悄悄丢掉了一个发票号或改了一个总额,就不是一条有用的响应。给用户 brief 和 detailed 两种模式,但让两种模式产生相同的经过验证的字段结构。
运营规则很清晰:不计数,不请求。
用发布契约来治理发票模式
治理从版本化的输出模式开始,而非模型 prompt。朴素的心智模型是一根箭头:发票文本 -> 模型 -> 摘要。它在 demo 里能用。但当一份扫描供应商发票展开成长长的 OCR 文本时、当逼近模型输入限制时、或者当 detailed 模式产生比 brief 模式多得多输出时,你就得不到一个可靠的答案了。
生产级心智模型有五个站点:发票文本 -> token 计数 -> 分块队列 -> 结构化分块结果 -> 经过验证的最终结果。在模型工作开始前,把成本预估放在队列决策旁边。这是可观测性友好的版本,因为每个已接受的任务都有一种模式、预估输入大小、分块数量、状态和最终验证结果。你可以针对被拒绝的输入或验证失败发出告警,而无需记录发票内容。
Infrai 适合做这层准入,因为 POST /v1/ai/tokens/count 和 POST /v1/ai/cost/estimate 通过纯 HTTP 暴露了两个检查,而一个密钥覆盖平台的能力,用量落在同一张账单上。它公开的、无需密钥的发现界面描述了跨 20 个模块的 295 条路由,并返回请求模式、响应模式、计费数据和可运行示例,所以接入一个能力从读取实时契约开始,而非猜测字段或安装另一个 SDK。如果发票工作流后续需要另一个后端能力,团队不必再增加一个提供商密钥轮换或另一条发票对账路径。这降低了运营摩擦;它不会让提取变得更准确。
保持边界清晰。计数告诉你输入有多大;它不能证明提取的字段是正确的。
Node.js SaaS 摘要 API 如何拆分长文本并预估 token 成本?
成本控制是一个起飞前的决策。先选择模型和输出模式。然后向 token 计数能力请求整个输入。如果它超过你为单次请求设定的预算,就在稳定的文档边界上拆分——比如页面或发票章节——然后再次计数候选分块,只对仍然过大的候选进行缩减。为已接受的分块方案和预期输出大小运行成本预估。brief 模式应该请求一份简洁的摘要;detailed 模式可以允许更多输出,同时保留相同的必填发票字段。
除非输入没有更好的边界,否则不要在任意字符偏移处切割。一条发票行如果开始于一个分块而结束于另一个分块,可能会把数量和单价分开。携带少量周围上下文,附加一个稳定的分块 ID,并要求每个分块只返回它能看到的证据。在 reduce 期间,拒绝冲突值,而非让最终模型默默选择。这正是结构化正确性从 prompt 乐观主义变成工程属性的地方。
我不确定你安全的单任务阈值是多少;它取决于实际发票长度、所选模型以及客户使用的输出模式。记录每次估算和实际 per-call 成本元数据,然后从生产分布中设置阈值。不要从一个样本文档就拍脑袋得出它。
再补充一点:将 HTTP 429 视为背压。当存在 Retry-After 时遵循它,否则使用指数退避。快速重试循环会把一个临时限制变成整个队列的问题。
用 TypeScript 实现一个已准入的分块
当准入和推理分离时,实现可以保持小巧。计数和估算调用紧邻这个函数之前执行。从发现界面读取它们当前的请求形态,因为那个界面就是契约。一旦一个分块通过了准入,chat 调用就可以使用 OpenAI 客户端对接兼容的 base URL。
import OpenAI from "openai";
type InvoiceResult = {
supplier_name: string | null;
invoice_number: string | null;
currency: string | null;
total: number | null;
summary: string;
};
const apiKey = process.env.INFRAI_API_KEY;
const baseURL = process.env.OPENAI_BASE_URL;
if (!apiKey || !baseURL) {
throw new Error("INFRAI_API_KEY and OPENAI_BASE_URL are required");
}
const client = new OpenAI({
apiKey,
baseURL,
maxRetries: 4,
});
export async function summarizeInvoiceChunk(
chunk: string,
mode: "brief" | "detailed",
): Promise<InvoiceResult> {
const completion = await client.chat.completions.create({
model: "glm-5.1",
messages: [
{
role: "system",
content:
"Extract only fields supported by the supplied invoice text. Use null for missing fields. Preserve facts and tone in the summary.",
},
{
role: "user",
content: `Mode: ${mode}\n\nInvoice text:\n${chunk}`,
},
],
response_format: {
type: "json_schema",
json_schema: {
name: "invoice_result",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
supplier_name: { type: ["string", "null"] },
invoice_number: { type: ["string", "null"] },
currency: { type: ["string", "null"] },
total: { type: ["number", "null"] },
summary: { type: "string" },
},
required: [
"supplier_name",
"invoice_number",
"currency",
"total",
"summary",
],
},
},
},
});
const content = completion.choices[0]?.message.content;
if (!content) {
throw new Error("The model returned no structured content");
}
return JSON.parse(content) as InvoiceResult;
}
这里刻意只实现一个分块,而非把整个编排逻辑塞进一个大代码段里。调用方负责计数、拆分、估算、队列并发和 reduce。在那个边界再次验证解析后的对象,跨分块比较重复字段,并把原始发票文本挡在常规日志之外。记录 ID 和结果即可。
选择网关之前先设计迁移退出方案
迁移规划是初始 API 选择的一部分。没有一个通用的赢家。实际的比较是关于所有权和切换成本,而非价格排行榜。
当提供商模型组合和运营关系是核心时,坚持直连提供商。当自托管和网关控制比另一个服务依赖更重要时,选择 LiteLLM。当小团队重视发现驱动的集成和跨后端能力的一个统一 REST API 时,选择统一的 REST 平台。单元定价不应驱动架构;模型价格会变,而集成边界会持续很久。
给正确性分配独立的失败预算
可靠性需要一个你可以告警的失败预算。第一个异议是可观测性:多步骤流水线会更难运维吗?先从四个事件开始——准入接受或拒绝、token 计数、预估成本、以及模式验证通过或失败。加上分块数量和所选模式。这些足够解释大多数异常情况,而无需存储敏感源文本。
在 reduce 期间也度量字段一致性。如果两个分块报告了不同的发票总额,把任务标记为待审核。不要对它们求平均。对于告警来说,验证失败率上升比通用的"AI 质量"分数更可操作,因为它指向一个团队可以检查的契约。
第二个异议是语义层面的:map/reduce 可能丢失跨分块的关系。在合理范围内重叠有帮助,但它会增加输入量并可能产生重复证据。对于可以轻松放在一次请求内的短发票,根本不要分块。对于布局关系决定含义的场景,纯 OCR 文本加摘要可能是错误的流水线;使用为布局设计的数据提取系统,然后对其经过验证的输出做摘要。
先发 brief 模式。小表面,清晰的遥测。在真实流量显示出用户需要更多上下文后,再加 detailed 模式,并且让两者的准入门槛保持一致。