通过强制结构化输出校验作为入口门控,将长属性文本按语义分块,按 token 计数估算任务量,只发布经验证的摘要记录,避免低质数据快速涌入下游。
简短答案:将结构化输出验证作为属性目录摘要器的准入规则,然后按语义切分、计数 token、估算完整任务量,最后只发布一条经验证的记录。在这些控制手段到位之前,低价摘要 API 毫无用处;否则每次调用虽然便宜,但只会让劣质目录数据更快涌入。
属性描述是一种特别苛刻的输入。"2 bed, 1.5 bath, pet friendly" 可以和一段营销文案、一条粘贴的验房记录、一句未完成的句子并列出现。而输出必须是一条搜索、定价和列表页面都能信赖的记录。因此架构应该追求"可恢复的正确性",而不是最短请求路径。
实际形态是一个持久的 map-reduce 任务。planner 创建有边界的源文本块,worker 产出类型化的候选结果,validator 要么接受每个候选,要么将其发送到预定义的修复路径。reducer 看到的是已被接受的记录,而不是最先返回的任意文本。
从输出契约开始,而非 prompt
对于这个目录,契约可能包含 bedrooms、bathrooms、allows_pets、parking_spaces 和 amenities,每个字段都带有值、源跨度(source span)和置信度分类。Unknown 是一个合法值。仅仅因为描述中写了"two generous rooms"就猜测值为"2",这不是数据丰富,而是带友好语气的数据损坏。
契约需要普通 JSON 语法无法表达的规则。bathroom 计数不能为负。allows_pets 必须区分"允许宠物"和"宠物政策可申请"。源跨度必须指回用于提取的输入版本。如果模型返回一个语法上有效但没有任何证据支撑值的对象,验证应该拒绝该字段,同时保留候选结果的其余部分以供审查。
下面是一个本地验证边界。它有意独立于模型或托管 API,因为目录的正确性策略应该在提供商更换后依然成立。考虑这样一条列表:"two bedrooms plus a den; pets considered with approval; parking may be available."。一个粗心的提取器可能输出 bedrooms: 3、allows_pets: true 和 parking_spaces: 1,这些在一个数据库行中看起来都合理,但都夸大了源文本的含义。契约应该保持 bedrooms 为 2,将 den 表示为一个 amenity 或单独定义的字段,如果模式中没有"conditional"状态则让宠物决定保持未决,让 parking 未知。这个决定对于试图填满每一列的产品团队来说可能显得保守,但未知值可以审查和修正,而编造的事实在被任何人注意到之前就可能被索引、展示并复制到租约工作流中。validator 就是让这种纪律变得可执行的地方。
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Decision:
accepted: bool
reasons: tuple[str, ...]
def validate_property_record(record: dict[str, Any]) -> Decision:
reasons: list[str] = []
required = {"bedrooms", "bathrooms", "allows_pets", "amenities"}
missing = sorted(required - record.keys())
if missing:
reasons.append(f"missing fields: {', '.join(missing)}")
for name in ("bedrooms", "bathrooms"):
value = record.get(name)
if value is not None and (not isinstance(value, int) or value < 0):
reasons.append(f"{name} must be a non-negative integer or null")
if not isinstance(record.get("allows_pets"), (bool, type(None))):
reasons.append("allows_pets must be boolean or null")
if not isinstance(record.get("amenities"), list):
reasons.append("amenities must be a list")
for field in record:
if field not in required and field != "parking_spaces":
reasons.append(f"unexpected field: {field}")
return Decision(accepted=not reasons, reasons=tuple(reasons))
这不能证明模型理解了一个句子。它证明的是更窄但更有价值的东西:下游代码收到了契约承诺的形状。将语义检查、证据检查和人工审核规则放在这个边界附近,而不是埋在 prompt 文本里。
Node.js SaaS 如何切分、计数和预算长描述?
Node.js 服务可以编排工作,但字符数不是 token 数。一份带有特殊标点的长 amenity 列表和一段用其他书写系统的段落,消耗的 token 数可能与长度相近的营销段落截然不同。使用所选模型关联的 tokenizer 或计数面,并为指令和请求的结构化响应预留空间。
首先按段落或句子边界切分。如果候选结果仍然超过输入限额,再次切分;不要静默截断。planner 应在 worker 启动前在清单(manifest)中记录源偏移量、token 数、prompt 版本、输出预算和模型配置。reducer 同样需要处理,因为一组有效的部分记录仍然可能超过其自身的输入限额。
成本估算应覆盖整棵树:map 调用、中间归约、最终归约,以及产品模式选择的输出限额。preview 模式可能比 audit 模式更严格地限制输出。重要决定在准入之前做出,此时服务仍可以请求更简短的模式或在产生部分目录记录之前拒绝任务。
from dataclasses import dataclass
@dataclass(frozen=True)
class Chunk:
chunk_id: str
input_tokens: int
def plan(chunks: list[Chunk], input_limit: int, estimated_cost: float,
document_ceiling: float) -> dict[str, object]:
oversized = [chunk.chunk_id for chunk in chunks
if chunk.input_tokens > input_limit]
if oversized:
return {"decision": "split_again", "chunk_ids": oversized}
if estimated_cost > document_ceiling:
return {
"decision": "request_brief_mode_or_reject",
"estimated_cost": estimated_cost,
}
return {
"decision": "admit",
"chunk_count": len(chunks),
"estimated_cost": estimated_cost,
}
估算是控制信号,不是账单。在每次调用完成后存储实际用量,按文档类型和模式与估算值比较。大幅偏差通常意味着 planner 遗漏了 reducer 工作、输出限额过于宽松,或者样本语料库与生产环境不同。不同语言和模型配置下表现不同,所以通过一条英文列表测试的阈值并不能证明是安全的全局策略。
什么样的失败模式会让一个看似合理的摘要变得不安全?
危险的失败是静默的。worker 可能遗漏一个块、合并两个属性、将未解决的宠物政策变成 false,或者返回一个完全可解析但包含虚构值的对象。重试可能覆盖一个更好的候选结果,而 reducer 可能发布一条流畅的记录,却无人注意到某个源范围缺失。
将每个候选结果视为携带证据的提案。保留输入版本、源偏移量、块标识符、schema 版本、prompt 版本、验证结果和用量元数据。默认不记录原始描述;目录文本可能包含姓名、电话号码或出入信息。日志应解释决策而不成为租户数据的又一份副本。
清单是持久的工作单元。完成意味着每个预期的子项都有一个被接受的结果,且 reducer 已写入输出。仅凭状态标志是不够的。如果最终对象在清单转换之前写入,读者可能观察到任务后续认为不完整的结果。使用适合该存储的原子元数据转换或 compare-and-set 操作,并使结果键从源版本和配置中确定性派生。
在比较模型之前,我会用对抗性 fixture 测试 validator:"one bedroom plus den"、"no pets"、"pets considered"、矛盾的 bathroom 计数、重复的 amenity、空描述,以及一条在句子中途截断的列表。预期输出应说明哪些保持未知。漂亮的摘要不是通过的测试。
通过留下的后续工作来比较架构选择
没有适合 SaaS 功能的通用最佳摘要 API。有效的比较是:在模型调用之后,哪些职责仍留在你的服务中。
这张表是对"只比较请求价格"的警告。gateway 可以简化模型路由但增加运维面。直接调用可以减少移动部件但将策略和可观测性留给应用代码。队列可以保护 web 进程但让重复投递和取消成为明确的设计问题。
只有当候选提供方能满足契约并为你的预算策略暴露足够的用量信息时,才保留它。拒绝那些使 schema 验证、源证据或删除保证无法验证的实现。需要注意的是,当产品需要提供商特定功能且无法用你的中立契约表示时,此建议不适用;此时使用原生集成,并在同一 planner 和 validator 之后将其隔离。
以可观测的决策推进上线
从一个文档类型和两个用户可见模式开始,如 preview 和 verified。发出准入决策、切分深度、估算-实际偏差、验证拒绝原因、重试次数、reducer 深度和人工审阅等待时间的指标。不要使用单一的"AI success"计数器;它会掩盖损害目录的失败。
将进度暴露为从清单派生的状态。Server-Sent Events 可以将状态变化流式传输到浏览器,但开放连接不等于作业所有权。重连的客户端应读取当前清单并从持久游标继续;worker 应在浏览器消失后继续运行。MDN 的 SSE 指南对传输细节有帮助,而作业状态属于你的应用数据层。
对于迁移,将新的提取器对现有描述进行影子运行,逐字段比较分歧,并抽样拒绝的记录。只发布通过契约的记录。一旦拒绝原因稳定下来,逐步提高并发并保留回滚到先前目录值的路径。当正确性可衡量时上线才算完成,而非第一批返回摘要时。
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
https://github.com/BerriAI/litellm
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events