一个平台,两个团队,三种工单
周三上午,平台团队收到三张工单。客服团队想把 chat.reply 的档位从 standard 调到 frontier,因为退款话术不能出错;内容团队想把团队份额从 4,000 万微元调到 1,200 万微元,理由是双十一要提前备量;合规团队问,能不能让一个境外租户的评测语料在境内团队可见。
三张工单看起来都是「配置一下」,实际分属三种性质。第一张改变了能力的成本与质量边界,第二张突破了团队份额的自助上限,第三张涉及数据跨境。如果平台把这些都做成按钮,业务方会按错;如果都做成工单,平台团队会被日常请求淹没。
自助服务的边界,就是回答「哪些按钮可以给人按,哪些必须有人签字」。它不是一个 UI 问题,而是一组可判定的路由规则,加上每一类人工请求的响应时限。
四类自助与三类人工
先把请求分类。分类的依据不是「操作是否复杂」,而是「越界后谁来承担后果」。
| 请求 | 默认路由 | 判定依据 | 越界后果 |
|---|---|---|---|
| 申请能力(开通已准入能力) | 自助 | 能力在准入目录、档位开放 | 成本进入团队份额 |
| 配团队配额 | 自助 | 不超过自助上限、不超过租户余额 | 挤占其他团队 |
| 看账单与用量 | 自助 | 只读 | 无 |
| 查评测与跑公开评测 | 自助 | 语料非受限、单次预算内 | 消耗评测预算 |
| 新能力上线 | 人工 | 不在准入目录 | 质量、安全、合规未知 |
| 跨租户借用额度 | 人工 | 涉及两个租户的商务承诺 | 一方挤占另一方 |
| 合规例外 | 人工 | 涉及保留、跨境或敏感数据 | 法律与声誉风险 |
这张表的关键在最后一行:合规例外永远不自助。不是因为它操作难,而是因为它的后果不可逆。平台可以把申请表单做得很顺滑,但不能把签字权也做进表单。
反过来,看账单永远自助。它只读、不改变任何状态,没有任何理由让人工审批插在中间。一个平台如果连账单都要工单,说明它的权限模型还没拆开「读」与「写」。
四个判定条件
七类请求可以收敛成四个条件。每个条件回答一个「是否越界」的问题,全部为否才允许自助。
第一个条件是准入目录。能力必须已经通过平台的质量、成本、安全与合规评审,才允许业务方自助开通。目录里没有的能力,哪怕调用方式与已有能力一模一样,也走人工。这条规则防止「换个名字绕过评审」。
第二个条件是档位开放。同一个能力,economy 与 standard 可以自助,frontier 默认人工。档位不是性能参数,它是成本与质量的承诺:frontier 的单次成本可能是 standard 的数倍,而且更容易触发供应商限流。让业务方自助打开 frontier,等于让他在没有容量评估的情况下改变整条链路的成本结构。
第三个条件是额度上限。团队可以在自助界面调整自己的份额,但有两个天花板:平台设定的单团队自助上限,以及租户当前剩余额度。超过任何一个,转人工。前者防止单一团队把租户总额吃光,后者防止配置出一个账面上不存在的额度。
第四个条件是语料隐私。公开语料与租户自有语料可以自助跑评测;标记为 restricted 的语料,哪怕调用方就是语料所有者,也要人工确认可见范围。评测平台最容易泄漏隐私的地方不是结果页,而是「谁能把哪些样本放进同一个评测集」。
四个条件里,前三个是数值与目录判断,可以完全自动化;第四个涉及语义与授权,必须有人工环节。这也是为什么自助边界不能只写一句「按风险分级」,而要落到具体字段。
审批时限:24 小时、5 个工作日、10 个工作日
人工请求如果没有时限,就会退化成邮件列表。平台要对每类人工请求承诺首次响应时间,并明确「超时只升级,不自动批准」。
| 人工请求 | 首次响应时限 | 折算 | 超时动作 |
|---|---|---|---|
| 额度超过自助上限 | 24 小时 | 1 个工作日 | 升级至团队负责人 |
| 租户余额不足 | 24 小时 | 1 个工作日 | 升级至商务负责人 |
| 受限语料评测 | 24 小时 | 1 个工作日 | 升级至数据负责人 |
| 跨租户借用 | 24 小时 | 1 个工作日 | 升级至两个租户负责人 |
| 合规例外 | 120 小时 | 5 个工作日 | 升级至合规负责人 |
| 新能力上线 | 240 小时 | 10 个工作日 | 升级至平台评审委员会 |
时限的设计有一个取舍:越接近「配置调整」的请求,时限越短;越接近「新资产准入」的请求,时限越长。额度与借用是已有资产的重新分配,一天内必须给答复;新能力上线要做评测、容量与合规三份材料,十个工作日是合理下限。
合规例外放在 5 个工作日,是因为它需要法务与数据保护官共同判断,不能为了追求「平台响应速度」而压缩审查。时限短不等于治理好,时限与风险匹配才是。
路由与时限的实现
下面的模块只做三件事:按四个条件给出路由、计算首次响应截止时间、统计自助覆盖率。它不读写数据库,也不调用审批系统。
// file: src/gov/platform-eng.ts
// tier: L2
// verify: node --test test/gov/platform-eng.test.ts
/** 平台请求分两类:能自助完成的,和必须有人拍板的。 */
export type RequestKind =
| "enable-capability"
| "set-team-quota"
| "view-billing"
| "run-evaluation"
| "launch-capability"
| "cross-tenant-borrow"
| "compliance-exception";
export type ServiceRoute = "self-service" | "manual";
export type CorpusPrivacy = "public" | "tenant" | "restricted";
export interface PlatformPolicy {
/** 已通过准入、允许业务方自助开通的能力。 */
approvedCapabilities: readonly string[];
/** 自助开通允许选择的档位;frontier 默认不进自助。 */
selfServiceTiers: readonly string[];
/** 团队可自助调整的份额上限;超过即转人工。 */
maxSelfServiceQuotaMicros: number;
/** 单次评测的预算上限,防止评测把生产预算跑穿。 */
evalBudgetMicros: number;
}
export interface ServiceRequest {
kind: RequestKind;
capability?: string;
tier?: string;
requestedQuotaMicros?: number;
tenantRemainingMicros?: number;
corpusPrivacy?: CorpusPrivacy;
evalCostMicros?: number;
}
export interface RouteDecision {
kind: RequestKind;
route: ServiceRoute;
/** 自助为 0;人工为首次响应的时限,单位小时。 */
slaHours: number;
dueAtMs: number;
reason: string;
escalation: string;
}
/** 人工请求的首次响应时限;时限是承诺,不是审批必然通过的保证。 */
export const MANUAL_SLA_HOURS: Record<RequestKind, number> = {
"enable-capability": 240,
"set-team-quota": 24,
"view-billing": 0,
"run-evaluation": 24,
"launch-capability": 240,
"cross-tenant-borrow": 24,
"compliance-exception": 120,
};
const HOUR_MS = 3_600_000;
function isNonNegativeInteger(value: number | undefined): value is number {
return value !== undefined && Number.isInteger(value) && value >= 0;
}
function decide(
request: ServiceRequest,
route: ServiceRoute,
reason: string,
nowMs: number,
): RouteDecision {
const slaHours = route === "self-service" ? 0 : MANUAL_SLA_HOURS[request.kind];
return {
kind: request.kind,
route,
slaHours,
dueAtMs: nowMs + slaHours * HOUR_MS,
reason,
escalation: route === "self-service" ? "无需审批" : "超时升级至平台值班与对应负责人",
};
}
/**
* 路由只看四件事:动作是否在准入目录内、档位是否开放、额度是否在自助上限内、
* 语料是否允许跨团队可见。任何一项越界就转人工,不在自助界面里做例外。
*/
export function routeRequest(
policy: PlatformPolicy,
request: ServiceRequest,
nowMs: number,
): RouteDecision {
switch (request.kind) {
case "view-billing":
return decide(request, "self-service", "read-only", nowMs);
case "enable-capability": {
const capability = request.capability ?? "";
if (!policy.approvedCapabilities.includes(capability)) {
return decide(request, "manual", "capability-not-approved", nowMs);
}
if (!policy.selfServiceTiers.includes(request.tier ?? "")) {
return decide(request, "manual", "tier-requires-review", nowMs);
}
return decide(request, "self-service", "approved-catalog", nowMs);
}
case "set-team-quota": {
const requested = request.requestedQuotaMicros;
const remaining = request.tenantRemainingMicros;
if (!isNonNegativeInteger(requested) || !isNonNegativeInteger(remaining)) {
return decide(request, "manual", "invalid-quota", nowMs);
}
if (requested > policy.maxSelfServiceQuotaMicros) {
return decide(request, "manual", "above-self-service-ceiling", nowMs);
}
if (requested > remaining) {
return decide(request, "manual", "tenant-capacity", nowMs);
}
return decide(request, "self-service", "within-ceiling", nowMs);
}
case "run-evaluation": {
if (request.corpusPrivacy === "restricted") {
return decide(request, "manual", "restricted-corpus", nowMs);
}
if (!isNonNegativeInteger(request.evalCostMicros)) {
return decide(request, "manual", "invalid-eval-cost", nowMs);
}
if (request.evalCostMicros > policy.evalBudgetMicros) {
return decide(request, "manual", "above-eval-budget", nowMs);
}
return decide(request, "self-service", "public-or-tenant-corpus", nowMs);
}
case "launch-capability":
return decide(request, "manual", "new-capability-onboarding", nowMs);
case "cross-tenant-borrow":
return decide(request, "manual", "cross-tenant-transfer", nowMs);
case "compliance-exception":
return decide(request, "manual", "compliance-exception", nowMs);
}
}
/** 超时只改变升级对象,不自动批准;自助请求的时限恒为 0。 */
export function overdueRequests(
decisions: readonly RouteDecision[],
nowMs: number,
): RouteDecision[] {
return decisions.filter(
(decision) => decision.route === "manual" && decision.dueAtMs <= nowMs,
);
}
/** 自助覆盖率的分母是全部请求,不是人工请求;它衡量的是边界是否划得够宽。 */
export function selfServiceRatio(decisions: readonly RouteDecision[]): number {
if (decisions.length === 0) return 0;
return decisions.filter((decision) => decision.route === "self-service").length / decisions.length;
}
routeRequest 的每个分支都返回同一个结构:路由、时限、原因与升级对象。原因码是给看板聚合用的,不是给业务方看的文案;业务方看到的是「你的请求超过自助上限,已转人工,24 小时内响应」。把原因码与文案分开,才能既做统计又不泄漏内部规则。
overdueRequests 只把人工请求计入超时。自助请求的时限是 0,但它没有「超时」的概念,因为它在请求返回时就已经完成。把自助请求算进超时率,会让平台团队误以为自己在积压。
selfServiceRatio 的分母是全部请求,不是人工请求。这个口径很重要:如果分母只算人工请求,自助率永远是 100%;只有把自助与人工放在一起,才能看出边界划得够不够宽。
示例样本上的运行结果
用 10 个请求跑一遍:4 个自助、6 个人工,自助覆盖率 40%。示例时间从 0 开始,所有数字都是示例样本上的运行结果。
| 请求 | 路由 | 原因 | 截止时间 |
|---|---|---|---|
| 查看账单 | 自助 | read-only | 0 |
开通摘要 standard | 自助 | approved-catalog | 0 |
| 份额调到 800 万 | 自助 | within-ceiling | 0 |
| 公开语料评测 5,000 微元 | 自助 | public-or-tenant-corpus | 0 |
开通 chat.reply frontier | 人工 | tier-requires-review | 240 小时 |
| 份额调到 1,200 万 | 人工 | above-self-service-ceiling | 24 小时 |
| 开通未准入能力 | 人工 | capability-not-approved | 240 小时 |
| 受限语料评测 | 人工 | restricted-corpus | 24 小时 |
| 跨租户借用 | 人工 | cross-tenant-transfer | 24 小时 |
| 合规例外 | 人工 | compliance-exception | 120 小时 |
这张表里有两个容易误读的地方。第一,份额 1,200 万被拒不是因为租户没钱,租户还剩 6,000 万,而是因为它超过了单团队 1,000 万的自助上限;平台要的是「大额调整必须有人看一眼」,不是「没钱了才找人」。第二,受限语料评测被拒不是权限不足,而是可见范围需要人工确认;同一个评测集,换成公开语料就能自助。
如果再加入两个请求——新能力上线与份额超过租户余额——人工数会变成 8,自助覆盖率降到 33.33%。这说明自助覆盖率不是一个越高越好的指标:它只描述当前请求结构,不能单独作为平台团队的 KPI。真正要看的,是「人工请求里有多少本来可以自助」以及「自助请求里有多少事后被回滚」。
自助界面的三个护栏
边界划好之后,界面还要防止「合法但错误」的操作。平台至少要给三个护栏。
第一是预演。调整配额前,界面要显示调整后的三层余额、受影响的链路与预计可支撑的调用次数。回声的例子是:内容团队把份额从 4,000 万调到 1,000 万,摘要单次 6,200 微元,预计可支撑 16,129 次;如果团队日均调用 20,000 次,界面应该直接提示「按当前用量只够 0.8 天」。没有预演,用户只能通过被拒绝来学习。
第二是二次确认。不是所有自助操作都要弹窗,只有会改变成本或质量承诺的操作需要:切换档位、调整份额、开启新能力。确认框里要写清楚「这次改动从什么时候生效、影响哪些链路、如何回滚」,而不是一句「确定吗」。
第三是变更审计。每一次自助变更都要记录操作者、时间、旧值、新值与生效范围。审计日志不是给合规看的摆设,它是事后回答「为什么这条链路突然变贵」的唯一证据。没有审计,自助就会变成不可解释的变更。
三个护栏合起来,才让「自助」不等于「失控」。自助服务的目标不是减少人工,而是把人工集中到真正需要判断的地方;护栏的作用是让不需要判断的操作也不容易出错。
与配额、能力目录的衔接
自助边界不是孤立的。它依赖两个已有资产:能力目录与三层配额。
能力目录提供「准入」的事实:哪些能力可以开通、每个能力的默认档位、可覆盖参数与计费单位。没有目录,平台无法判断「申请能力」是否越界,只能靠人工记忆。
三层配额提供「额度」的事实:租户总额、团队份额、单次上限。没有配额,平台无法判断「配份额」是否超过租户余额,也无法在预演里算出「够跑多少次」。
反过来说,自助边界也给这两个资产提出要求。目录必须把「自助可开通的档位」写成字段,而不是散落在文档里;配额必须暴露「租户当前剩余」的只读接口,否则界面无法做预演。治理资产之间不是并列关系,它们互相提供判定所需的事实。
什么时候应该收紧边界
边界不是一次划完就固定。出现下面三种信号时,平台应该收紧自助范围。
第一种,同一类自助操作在短期内被回滚多次。说明用户并不理解它的后果,或者默认值不合理。先修默认值与预演,再考虑收紧;如果修完仍然回滚,就转人工。
第二种,自助变更导致了跨租户影响。比如一个团队把份额调高,挤占了另一个团队的借用额度。这说明自助上限没有考虑租户层的联动,应该把「租户剩余」纳入判定,而不是简单调低上限。
第三种,合规或安全团队对某类语料提出新的可见性要求。这类变化通常来自外部,不是平台能预测的;正确动作是把对应评测请求直接转为人工,并在目录里标记语料级别,而不是加一个「承诺不泄漏」的复选框。
收紧边界时要注意可解释性。用户必须知道「为什么昨天可以、今天不行」,否则他会认为平台在随意变更规则。每一次收紧都要同时更新原因码、界面文案与时限表,让规则可查、可复现。
小结
自助服务的边界由四个条件决定:能力在准入目录、档位开放、额度在上限内、语料不受限。四类请求可以自助,三类请求必须人工;人工请求按风险给出 24 小时、5 个工作日与 10 个工作日三档时限,超时只升级、不自动批准。
示例样本的 10 个请求里,4 个自助、6 个人工,自助覆盖率 40%。这个数字不是越高越好,它只描述请求结构;真正要盯的是人工请求里有多少本可自助,以及自助变更的回滚率。
阅读导航:上一节:9.3 事故响应与披露 · 下一节:10.2 SDK、CLI 与配置分发 。
常见错误与边界
- 把自助理解成「所有按钮都放开」。没有准入、档位、额度与语料四个条件,自助会变成风险放大器。
- 把人工理解成「都走工单」。只读的账单、目录查询也走工单,平台团队会被无意义的请求淹没。
- 只按操作复杂度分类。合规例外操作很简单,但后果不可逆,必须人工。
- 审批没有时限。工单会退化成邮件列表,业务方无法预期。
- 超时自动批准。时限是响应承诺,不是风险豁免;超时只能升级。
- 自助上限只看团队份额,不看租户剩余。会配置出账面上不存在的额度。
- 把 frontier 当成普通档位开放。成本与限流风险会在没有容量评估时被放大。
- 自助界面没有预演。用户只能通过被拒绝来学习规则。
- 变更没有审计。事后无法回答「这条链路为什么突然变贵」。
- 用自助覆盖率做唯一 KPI。它会奖励「把风险操作伪装成自助」,而不是奖励「把边界划对」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。