一次截断事故暴露的问题
「回声」同时跑着三类 AI 调用:面向用户的智能回复走 frontier,内容审核走 economy,搜索重排按用户所在区域选择提供方。三类调用共用同一个网关,网关手里有一张模型清单,每个模型标注能力、档位、上下文窗口、价格和区域。
某天运营反馈:长文摘要功能在部分文章上只输出半句话。查日志发现,请求命中的是一个 economy 模型,输出上限 512 token,而文章正文有六千多字。网关没有报错,它只是忠实地执行了“在成本上限内选最便宜的模型”。问题不在模型,也不在网关的排序算法,而在请求进入网关之前:业务侧只声明了“我要摘要”,没有声明“摘要至少要完整覆盖正文”和“输出至少 800 token”。
这就是路由的第一个事实:模型选择不是网关单方面的事。网关能算价格、能探健康、能看区域,但它不知道一篇长文摘要“怎样才算没被截断”。这类判断必须由业务侧声明,网关只负责执行。
文中出现的模型名、价格、延迟与比例,除本机测试输出外都是案例假设值,用来演示判断过程,不是任何真实提供方的报价或 SLA。
五个维度分别回答什么
一次路由决策要同时回答五个问题。把它们叫作维度,是因为每个维度都会独立地缩小候选集合,任何一个维度缺输入,路由就只能猜。
任务:要完成什么
任务维度回答的是能力标识、质量下限和输出形态。能力标识决定哪些模型有资格,质量下限决定能降到哪一档,输出形态决定上下文窗口和最大输出是否够用。任务维度最容易被写成一句“调用摘要接口”,然后丢掉后面两个约束。
租户:谁在调用
租户维度回答的是调用方身份与合约。免费租户、付费租户和企业租户能用的档位不同,数据能否出域、能否使用某个提供方也不同。租户维度的输入来自商务合约与产品策略,不是网关能从请求头推断出来的。
区域:请求落在哪里
区域维度回答的是请求应该落在哪个区域,以及哪些提供方在该区域可用。用户在香港、法兰克福还是弗吉尼亚,可选模型、延迟和合规要求都不一样。区域事实变化快,必须由网关在请求时读取提供方区域表,不能由业务写死。
成本:这一次能花多少
成本维度回答的是预算与计价。业务侧可以给出一个软上限,但具体模型的单价、输入输出 token 比例、实际扣费必须由网关用最新价格表计算。业务自己算价,价格表一更新就会全面失真。
健康:现在谁能用
健康维度回答的是当下可用性与恢复节奏。模型可能因为提供方故障、限流或自身错误率升高而暂时不可用,也可能在熔断冷却后需要一次半开探针。健康状态是秒级变化的运行时事实,只有网关掌握。
为什么任务与租户归业务侧
任务与租户的共同点是:它们描述“想要什么”,而不是“现在有什么”。这类事实只有业务侧掌握,而且一旦缺失,网关的任何默认值都会变成猜测。
任务维度放业务侧,因为“摘要是否完整”“回复是否可以直接发给用户”“审核漏放和误杀的代价哪个更高”是产品判断。网关可以校验输出长度,但它无法判断一段三百字摘要是否覆盖了原文的五个要点。
租户维度放业务侧,因为“这个租户买了什么”是合约事实。网关可以执行配额和隔离,但不应该自己推断某个租户“看起来像付费用户”。把租户等级交给网关猜,最常见的后果是免费租户被误升到 frontier,月底账单出现一批无法解释的成本。
为什么区域、成本与健康归网关
区域、成本与健康的共同点是:它们描述“现在有什么”,并且变化频率远高于业务发布频率。放到业务侧会产生三种典型问题:缓存过期、口径分裂、恢复滞后。
区域如果由业务写死,跨境流量和合规区域会随用户移动而漂移;成本如果由业务计算,每个调用方都会维护一份价格表,任何一次调价都要改所有业务;健康如果由业务缓存,提供方恢复后业务仍然绕开可用模型,故障被“记住”得比实际更久。
这里要区分“主权”和“执行”。区域、成本、健康由网关决策,不代表业务完全不能提供输入。业务可以给区域提示、成本软上限和降级偏好,但最终选哪个模型、是否熔断、何时半开,必须由网关统一裁决,否则同一个租户在不同业务线会得到不同的可用性。
把维度写成可测试的输入
下面的模块把五个维度写成固定顺序的规格,并提供一个规划器:业务意图进去,维度归属、阻断项和网关待补事实出来。它不连接任何模型,只负责回答“这一维该由谁决策”。
// file: src/routing/dimensions.ts
// tier: L1
// verify: node --test test/routing/dimensions.test.ts
import type { CapabilityId, QualityTier } from "../core/types.ts";
/** 路由必须同时回答的五个问题;顺序固定,便于比较与测试。 */
export type RoutingDimension = "task" | "tenant" | "region" | "cost" | "health";
/** 决策主权:业务侧声明意图,网关侧执行机制。 */
export type DecisionOwner = "business" | "gateway";
export type TenantPlan = "free" | "plus" | "enterprise";
export interface DimensionSpec {
dimension: RoutingDimension;
owner: DecisionOwner;
/** 这一维回答的问题。 */
question: string;
/** 业务侧能提供的输入(可以为空);网关负责补全运行时事实。 */
businessInput: string;
/** 放到错误一侧时最常见的后果。 */
misplacedCost: string;
}
export const ROUTING_DIMENSIONS: readonly RoutingDimension[] = [
"task",
"tenant",
"region",
"cost",
"health",
];
export const DIMENSION_SPECS: readonly DimensionSpec[] = [
{
dimension: "task",
owner: "business",
question: "这次请求要完成什么,质量下限与输出形态是什么",
businessInput: "capability + qualityFloor + maxOutputTokens",
misplacedCost: "网关按价格猜任务,长文摘要被短输出上限截断",
},
{
dimension: "tenant",
owner: "business",
question: "哪个租户、买了什么档位、能否使用高成本模型",
businessInput: "tenantPlan + tenant policy",
misplacedCost: "网关自行推断租户等级,免费租户被误升到 frontier",
},
{
dimension: "region",
owner: "gateway",
question: "请求应该落在哪个区域,哪些提供方在该区域可用",
businessInput: "userRegion(可缺省,由边缘推断)",
misplacedCost: "业务写死区域,跨境流量与合规区域发生漂移",
},
{
dimension: "cost",
owner: "gateway",
question: "这次请求在预算内能选哪些模型,实际花费多少",
businessInput: "costCeilingMicros(软上限)",
misplacedCost: "业务自己算价,价格表更新后所有预算同时失真",
},
{
dimension: "health",
owner: "gateway",
question: "哪些模型当下可用,熔断后何时允许再进入",
businessInput: "无(由网关探测)",
misplacedCost: "业务缓存健康状态,故障恢复后仍长期绕开可用模型",
},
];
/** 按维度取规格;未知维度属于配置错误,直接抛出而不是静默返回 undefined。 */
export function dimensionSpec(dimension: RoutingDimension): DimensionSpec {
const spec = DIMENSION_SPECS.find((item) => item.dimension === dimension);
if (!spec) {
throw new Error(`unknown routing dimension: ${dimension}`);
}
return spec;
}
export interface RoutingIntent {
capability: CapabilityId;
qualityFloor: QualityTier | null;
tenantPlan: TenantPlan | null;
/** 用户区域;业务侧可以缺省,由网关从边缘推断。 */
userRegion: string | null;
/** 单次请求的成本软上限;小于等于 0 表示未声明。 */
costCeilingMicros: number;
}
export interface DimensionPlan {
dimensions: DimensionSpec[];
businessOwned: DimensionSpec[];
gatewayOwned: DimensionSpec[];
/** 业务侧声明缺失;网关不得用默认值掩盖。 */
blockers: string[];
/** 网关需要在请求时补齐的运行时事实。 */
gatewayFacts: string[];
}
/**
* 把业务意图拆成五个维度,并标出谁负责决策。
* 业务侧缺失的输入进入 blockers,而不是被网关的默认值吸收。
*/
export function planDimensions(intent: RoutingIntent): DimensionPlan {
const blockers: string[] = [];
if (intent.capability.trim() === "") {
blockers.push("task:capability-missing");
}
if (intent.qualityFloor === null) {
blockers.push("task:quality-floor-missing");
}
if (intent.tenantPlan === null) {
blockers.push("tenant:plan-missing");
}
if (!Number.isFinite(intent.costCeilingMicros) || intent.costCeilingMicros <= 0) {
blockers.push("cost:ceiling-missing");
}
const specs = ROUTING_DIMENSIONS.map(dimensionSpec);
const gatewayFacts = [
"cost:live-price-sheet",
"health:probe-window",
"region:provider-region-table",
];
if (intent.userRegion === null || intent.userRegion.trim() === "") {
gatewayFacts.push("region:infer-from-edge");
}
return {
dimensions: specs,
businessOwned: specs.filter((spec) => spec.owner === "business"),
gatewayOwned: specs.filter((spec) => spec.owner === "gateway"),
blockers,
gatewayFacts: gatewayFacts.sort(),
};
}
export interface Placement {
dimension: RoutingDimension;
owner: DecisionOwner;
}
/**
* 审计维度主权划分。返回空数组表示五个维度都恰好由正确的一侧决策。
* 重复与缺失也会被记录,避免“只审了关心的两个维度”。
*/
export function auditPlacements(placements: readonly Placement[]): string[] {
const issues: string[] = [];
const seen = new Set<RoutingDimension>();
for (const placement of placements) {
if (seen.has(placement.dimension)) {
issues.push(`${placement.dimension}:duplicate`);
continue;
}
seen.add(placement.dimension);
const spec = dimensionSpec(placement.dimension);
if (placement.owner !== spec.owner) {
issues.push(
`${placement.dimension}:owned-by-${placement.owner}-expected-${spec.owner}`,
);
}
}
for (const dimension of ROUTING_DIMENSIONS) {
if (!seen.has(dimension)) {
issues.push(`${dimension}:missing`);
}
}
return issues;
}
这段代码有三个刻意的设计。
第一,维度顺序固定为任务、租户、区域、成本、健康。顺序固定不是为了好看,而是为了让测试可以直接比对,也让日志里的维度列表不会因为对象遍历顺序而变化。
第二,业务侧缺失的输入进入 blockers,不会被网关的默认值吸收。比如 qualityFloor 为 null 时,规划器不会替业务选一个 standard,而是明确返回 task:quality-floor-missing。默认值看起来让系统更“健壮”,实际上是把一个产品决策偷偷变成了网关决策。
第三,gatewayFacts 只列网关需要补齐的运行时事实,不列具体模型。区域表、价格表、探测窗口都会变,规划器只承诺“网关会补”,不承诺“补成什么”。这让维度规划与具体提供方解耦。
auditPlacements 是配套的审计函数。它接收一组“维度 → 决策方”的声明,检查是否有维度放错侧、重复或遗漏。评审时把架构图里的归属填进去跑一遍,比在会上争论“健康状态到底谁维护”更快。
测试与真实输出
测试覆盖五个维度的顺序、主权划分、阻断项、区域缺省、审计与确定性。在实验工程根目录执行:
node --test test/routing/dimensions.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 五个维度顺序固定且规格齐全 (0.74725ms)
✔ 任务与租户归业务侧,区域、成本、健康归网关 (0.147125ms)
✔ 完整意图没有阻断项,网关只补运行时事实 (0.067958ms)
✔ 业务侧声明缺失时逐项进入 blockers (0.066208ms)
✔ 区域可以缺省,由网关从边缘推断 (0.558042ms)
✔ 主权审计能发现放错侧、重复与遗漏 (0.263625ms)
✔ 同一份意图重复规划得到相同结果 (0.114584ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 146.467084
七个测试里,最重要的是“业务侧声明缺失时逐项进入 blockers”。它保证了规划器不会在缺少质量下限时悄悄给出一个默认档位。只要这条测试还在,任何“先跑起来再说”的默认值都进不了主路径。
复盘:便宜模型把长文摘要截断
回到开头的事故。事后复盘列出三个叠加因素:
- 业务侧只传了能力标识与正文,没有传质量下限和期望输出长度。
- 网关的成本维度看到 3000 微元的软上限,在 economy 档位里选了一个单价最低的模型。
- 输出校验只检查“是否为空”,没有检查
finishReason是否为length。
第一项是任务维度缺输入,第二项是成本维度正常工作,第三项是结果校验缺失。三者叠加,系统就会“成功地”返回一段被截断的摘要:HTTP 200,日志无错误,用户看到半句话。
修复不是把 economy 模型拉黑,而是补齐三个约束:业务侧声明 qualityFloor: "standard" 与 maxOutputTokens: 800;网关在筛选阶段检查“估算输入 + 请求输出 ≤ 上下文窗口”和“请求输出 ≤ 模型输出上限”;调用结束后检查 finishReason,为 length 时标记降级而不是当成成功。
修复后同一个请求会在候选筛选阶段被 economy 模型拒绝,原因写为 output-limit-too-small。用户可能仍然拿到较慢或较贵的结果,但不会再拿到一段假装完整的半句话。
| 复盘项 | 事故做法 | 修复做法 |
|---|---|---|
| 任务输入 | 只传能力标识 | 传质量下限与输出上限 |
| 成本决策 | 业务给上限,网关按最低价选 | 网关在预算内先过滤不满足输出的模型 |
| 结果校验 | 只检查非空 | 检查 finishReason 与长度 |
| 责任归属 | 认为“网关选错了” | 业务补声明,网关补校验 |
这张表说明,路由事故很少是单一维度的问题。更常见的形态是:业务侧少声明一个约束,网关侧少做一次校验,两个“看起来都不算错”的决定叠在一起,最终变成一个用户可见的缺陷。
一张表看清维度主权
| 维度 | 回答的问题 | 决策方 | 业务输入 | 放错侧的后果 |
|---|---|---|---|---|
| 任务 | 要完成什么 | 业务 | 能力、质量下限、输出上限 | 按价格猜任务,长文被截断 |
| 租户 | 谁在调用 | 业务 | 租户档位与策略 | 免费租户被误升档 |
| 区域 | 落在哪里 | 网关 | 区域提示(可缺省) | 写死区域,合规漂移 |
| 成本 | 能花多少 | 网关 | 成本软上限 | 各自算价,口径分裂 |
| 健康 | 现在谁能用 | 网关 | 无 | 缓存故障,恢复滞后 |
小结
路由的五个维度是任务、租户、区域、成本与健康。任务与租户描述“想要什么”,归业务侧声明;区域、成本与健康描述“现在有什么”,归网关实时决策。主权划分清楚,网关才不会用默认值替业务做产品判断,业务也不会用缓存替网关做运行时判断。
维度不是文档里的分类,而是请求里的输入。把缺失输入写成 blockers,把归属写成可审计的声明,路由才有机会在事故发生前拦住“所有参数都合法、结果却不可用”的请求。
下一节会把五个维度变成真正的候选筛选与排序:优先级、加权、健康回退,以及熔断冷却后如何重新进入。
阅读导航:上一节:3.3 ai-lab:实现零依赖模型网关 · 下一节:4.2 优先级、加权与健康回退 。
常见错误与边界
- 把“路由”理解成按价格排序。价格只是成本维度的输入,任务质量下限与输出形态同样会一票否决。
- 让网关替业务补默认质量档位。默认值会把产品决策变成基础设施决策,而且很难在事故后追溯。
- 把租户等级写进请求头由网关猜。租户档位来自合约,应该由业务侧解析后传入。
- 业务自己维护模型价格表。价格更新频繁,多处维护必然出现口径分裂。
- 业务缓存模型健康状态。缓存会让恢复滞后,故障被记住得比实际更久。
- 只按区域选模型,不校验模型是否真的声明了该区域。区域表是网关事实,不是模型名的隐含属性。
- 用“请求成功”代替“结果可用”。HTTP 200 与
finishReason: "length"可以同时成立。 - 维度归属一次定死。组织边界和提供方能力都会变,归属需要随版本审计。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。