让 LLM 稳定输出结构化数据,靠的不是在 prompt 里写一百行”请务必输出合法 JSON、字段名必须是 rowId、outcome 只能取这几个值”,而是把协议从 prompt 里拿出来,交给请求参数和校验层去保证。response_format: json_object 只约束”这是一个 JSON”,不约束”这个 JSON 里有什么”;真正可靠的方案是严格 JSON Schema(OpenAI Structured Outputs 兼容子集)加上服务端独立校验,prompt 只负责讲清楚”诊断的语义”这一件事。
这次重构的背景是:诊断链路批量读取生产 Agent 的客服对话,让模型对每条对话输出一个结构化诊断(结论、失败模式、证据、严重级别等)。升级前的协议事实上由 prompt 承担,结果是字段名漂移、枚举不全、单条异常放大成整批失败。升级后,字段集合、枚举值、数值边界全部收敛到一个静态 JSON Schema,与 Zod 校验共享同一组领域常量,模型输出的协议性错误在归并阶段按行隔离,不再污染同行。
这篇文章是这个系列里最”工程”的一篇,会完整拆解:问题复盘、方案取舍、严格 Schema 的具体设计、双 Schema 如何共享单一来源、prompt 职责怎么重新划分、行级归并与两阶段持久化,以及 Zod 一个很容易踩的坑——普通 z.object() 遇到额外字段是剥离而不是拒绝。

一、问题复盘:json_object 只管外壳,不管内容
先看升级前的真实状态。所有诊断请求固定发送:
{ "response_format": { "type": "json_object" } }
这个模式能保证模型输出的是一段可解析的 JSON,仅此而已。它不约束:
- 有哪些字段、字段名是
row_id还是rowId; outcome只能取哪些枚举值;failureModes是字符串数组还是枚举数组;rowId是不是正整数;- 数组是否至少有一个元素。
这些约束当时全部写在 prompt 里,靠模型”自觉遵守”。实际跑起来出现了几类典型的坏行:
1. 字段名不一致:一部分行输出 row_id,另一部分输出 rowId;
2. 枚举漂移:outcome 出现 SUCCESS、PASS、NEGATIVE、NONE 等混用;
3. 数组形态漂移:failureModes 有时是 ["LOGISTICS_DELAY"],
有时干脆是一个逗号分隔的字符串;
4. 证据为空:observedFacts 或 evidenceRowIds 返回空数组。
为了救这些坏行,早期还做过一层”语义别名归一化”:把 PASS 映射成 SUCCESS,把字符串拆成数组,把 row_id 改写成 rowId。这层代码短暂地降低了失败率,但带来一个更隐蔽的问题:归一化规则本身成为协议的一部分,而且只存在于应用代码里。模型不知道哪些别名会被救、哪些不会,下游统计也不知道哪些枚举值是被程序改写过的。协议被拆到了三个地方——prompt、模型的习惯、应用的归一化代码——任何一处改动都可能让另外两处失效。
第二个放大器在批量校验这一层。一次请求诊断二十条对话,响应是一个 items 数组。当时的解析逻辑是:对整个数组做一次 Zod 校验,任何一条不合法,整批标记失败。也就是说,十九条完全合格的诊断,会因为一条 outcome: "PASS" 整批丢弃、整批重试。重试后的新响应里可能又有新的坏行,失败被不断放大,覆盖率长期上不去。
还有一个修复链路自己的问题:当某条坏行触发”修复请求”时,修复 prompt 里列举的枚举值不全,模型照着修复 prompt 输出,又产出了主 schema 不认的值。协议在主链路和修复链路之间也漂移了。
把问题列成一张表:
| 问题 | 表层原因 | 深层原因 |
|---|---|---|
| row_id / rowId 混用 | 模型输出不稳定 | 字段协议放在 prompt 里,无强约束 |
| 枚举漂移(PASS、NEGATIVE 等) | 模型用语习惯 | 枚举没有机器可校验的定义 |
| 修复 prompt 枚举不全 | 文档没同步 | 协议有多个副本 |
| 整批失败 | 数组整体校验 | 没有”按行隔离异常”的归并层 |
| 归一化掩盖问题 | 兜底代码越写越多 | 协议边界模糊,坏数据被静默改写 |
结论很清楚:可靠性问题的根因不是模型不听话,而是协议没有被机器化。
二、备选方案与取舍
重构之前,真正可选的路线有三条。
方案一:完全放宽校验
既然模型输出不稳定,那就把 Zod 校验放宽到几乎什么都接受:枚举改成 z.string(),数组允许为空,字段全部 optional。下游聚类和统计时再做兜底处理。
这条路很快就被否了。诊断结果的核心消费者是失败模式聚类和 PM 报告,聚类的输入键就是 failureMode 和 severity 这几个枚举。枚举一旦放开,聚类键会碎成一地:REFUND_POLICY_ERROR、退款政策错误、refund_policy 会被当成三个不同的问题。放宽校验等于把脏数据原封不动传给下游,污染的是最贵的分析层。
方案二:写更长的 prompt
把字段表、完整的枚举列表、两三个 JSON 示例全部塞进 prompt,反复强调格式。
这条路我们其实已经走过,它的问题不是”不够长”,而是结构性的:prompt 是自然语言,约束力是概率性的。prompt 越长,模型对开头和结尾的格式指令遵循得越好,中间部分反而容易被长上下文冲淡;而且每次换模型、调温度、改对话样本,格式漂移就要重新观察一遍。更重要的是,prompt 里的协议没有机器可校验的形态——你没法对一段自然语言跑契约测试。
方案三:严格 Schema + 服务端防线(最终选择)
请求侧切换到 OpenAI Structured Outputs 兼容的严格 JSON Schema,让网关在解码阶段就限制输出必须符合 Schema;服务端保留一套独立的 Zod 严格校验作为第二道防线,并承担 JSON Schema 表达不了的语义约束。
三条路线的对比:
| 维度 | 放宽校验 | 更长 prompt | 严格 Schema + 防线 |
|---|---|---|---|
| 格式可靠性 | 差(放弃治疗) | 不稳定,概率性 | 协议保证 |
| 枚举一致性 | 无 | 靠模型自觉 | Schema 强制 |
| 协议副本数 | 多处 | prompt 一处但不可测 | 单一来源,可测试 |
| 下游数据质量 | 被污染 | 时好时坏 | 稳定 |
| 实现成本 | 低 | 低 | 中(契约+归并改造) |
选第三条路的代价是要做一次不小的改造:契约模块重写、批量校验逻辑重构成行级归并、持久化改成分阶段。但这些都是一次性的工程成本,换来的是协议问题从此退出运维视野。
三、严格协议设计:一份静态 JSON Schema
新协议的核心是一份静态的、与 OpenAI Structured Outputs 兼容子集兼容的 JSON Schema。先看请求侧怎么发:
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "instance_diagnosis",
"strict": true,
"schema": { "...": "见下文" }
}
}
}
strict: true 是关键开关:它要求 Schema 满足一整套严格子集的规则(所有对象 additionalProperties: false、所有字段进 required、枚举用 enum 而非 oneOf 等),换取的是网关在解码时逐 token 地限制输出——模型在物理上生成不出不符合 Schema 的内容。这和 prompt 里”请输出 JSON”有本质区别:前者是协议,后者是请求。
根对象刻意设计成最简的 envelope:
{
"type": "object",
"properties": {
"items": { "type": "array", "items": { "$ref": "#/$defs/diagnosisItem" } }
},
"required": ["items"],
"additionalProperties": false,
"$defs": {
"diagnosisItem": {
"type": "object",
"properties": {
"rowId": { "type": "integer", "minimum": 1 },
"scenarioKey": { "type": "string", "pattern": "^[A-Z0-9_]+$" },
"outcome": { "enum": ["SUCCESS", "FAILURE"] },
"failureModes": { "type": "array", "items": { "enum": ["..."] } },
"observedFacts": {
"type": "array",
"minItems": 1,
"items": { "type": "object", "properties": { "...": "..." } }
},
"severity": { "enum": ["P0", "P1", "P2", "OK"] },
"confidence": { "enum": ["HIGH", "MEDIUM", "LOW"] },
"evidenceRowIds": {
"type": "array",
"minItems": 1,
"items": { "type": "integer", "minimum": 1 }
}
},
"required": [
"rowId",
"scenarioKey",
"outcome",
"failureModes",
"observedFacts",
"severity",
"confidence",
"evidenceRowIds"
],
"additionalProperties": false
}
}
}
(真实契约里还有 scenarioName、intentKey、changeTargets 等字段,这里做了精简。)
几个设计决策值得展开:
- 根对象只有
items一个字段。 不在 envelope 里放summary、meta之类的”顺便”字段。envelope 的唯一职责是让批量响应有一个稳定的数组容器,任何额外职责都会成为未来的漂移点。 additionalProperties: false+ 全字段 required。 这是严格子集的硬性要求,也是防御的根基:模型不能少给字段,也不能多给字段。row_id之所以曾经能出现,就是因为旧协议没有”额外字段”这个概念。- 整数下限。
rowId用type: integer+minimum: 1,把 0、负数、浮点数、字符串数字全部挡在 Schema 层。 - key 正则。
scenarioKey限定^[A-Z0-9_]+$,防止模型输出带空格或中文的场景键,混进下游的分组统计。 - 数组最小数量。
observedFacts和evidenceRowIds设minItems: 1——一条没有证据的诊断是没有分析价值的,这个约束必须在协议层成立,不能指望 prompt 提醒。
注意这些约束全部是”结构性”的:字段集合、类型、枚举、边界。它们恰好是 JSON Schema 严格子集能表达的东西,这不是巧合,而是选型时就以”网关能表达什么”为边界来设计协议的。
四、双 Schema 单一来源:让协议不再漂移
严格 JSON Schema 上线后,一个新风险出现了:应用内部还有一套 Zod 校验。两套 Schema 描述同一个协议,只要有一处改了枚举、另一处忘了同步,就会出现”模型输出完全合法、服务端却拒绝”或反过来”服务端放行了下游不认识的数据”。
以前的失败模式会换一个地方重现。解决方案是把两套 Schema 的”词汇表”收敛为同一份领域常量:
// diagnosis-contract.ts —— 唯一的领域词汇表
export const taskOutcomes = ["SUCCESS", "FAILURE"] as const;
export const failureModes = [
"LOGISTICS_DELAY",
"REFUND_POLICY_ERROR",
"TOOL_CALL_ERROR",
"HALLUCINATED_ORDER",
"REPETITION_LOOP",
"ESCALATION_MISSED",
] as const;
export const diagnosisSeverities = ["P0", "P1", "P2", "OK"] as const;
export const diagnosisConfidences = ["HIGH", "MEDIUM", "LOW"] as const;
// Zod 侧:直接引用常量
export const instanceDiagnosisSchema = z
.object({
rowId: z.number().int().positive(),
scenarioKey: z.string().regex(/^[A-Z0-9_]+$/),
outcome: z.enum(taskOutcomes),
failureModes: z.array(z.enum(failureModes)),
observedFacts: z.array(observableFactSchema).min(1),
severity: z.enum(diagnosisSeverities),
confidence: z.enum(diagnosisConfidences),
evidenceRowIds: z.array(z.number().int().positive()).min(1),
})
.strict();
// JSON Schema 侧:同样从常量派生
export const instanceDiagnosisResponseJsonSchema = {
type: "object",
properties: {
items: { type: "array", items: diagnosisItemJsonSchema },
},
required: ["items"],
additionalProperties: false,
} as const;
const diagnosisItemJsonSchema = {
type: "object",
properties: {
rowId: { type: "integer", minimum: 1 },
scenarioKey: { type: "string", pattern: "^[A-Z0-9_]+$" },
outcome: { enum: [...taskOutcomes] },
failureModes: { type: "array", items: { enum: [...failureModes] } },
observedFacts: {
type: "array",
minItems: 1,
items: observableFactJsonSchema,
},
severity: { enum: [...diagnosisSeverities] },
confidence: { enum: [...diagnosisConfidences] },
evidenceRowIds: {
type: "array",
minItems: 1,
items: { type: "integer", minimum: 1 },
},
},
required: [...diagnosisFields],
additionalProperties: false,
} as const;
从此以后,新增一个失败模式只需要改常量数组一处,Zod 和 JSON Schema 同时生效。配套写了一组契约测试(contract parity tests),断言两套 Schema 的字段集合、required 集合、枚举值逐一相等:
expect(itemJsonSchema.required).toEqual(Object.keys(itemJsonSchema.properties));
expect(itemJsonSchema.properties.outcome.enum).toEqual([...taskOutcomes]);
expect(
instanceDiagnosisSchema.safeParse({ ...validItem, extra: true }).success
).toBe(false);
这类测试的价值在于把”协议一致性”变成一个可以在 CI 里失败的东西。以前协议漂移要等线上坏行出现才被发现,现在等价于一个编译错误。
五、Prompt 职责重划:只讲语义,不讲协议
Schema 接管协议之后,prompt 迎来一次大瘦身。所有这些内容从 prompt 里删除:
- 字段列表和字段说明;
- 枚举值的合法集合;
- JSON 输出示例;
- “必须输出 JSON""字段名不要写错”之类的格式祈使句。
Prompt 里剩下的只有三类语义信息:
1. 诊断语义:什么算 FAILURE(例如:承诺了不存在的物流单号、
把不可退的商品说成可退、循环重复同一句安抚话术),
observedFacts 应该描述"对话里可见的事实"。
2. 证据边界:observedFacts 只能记录对话中可见的现象,
不得填写不可见的内部根因(如"模型参数衰减""检索索引损坏")。
诊断是黑盒的,看得见什么就记什么。
3. rowId 一一对应:每个输入行必须给出对应 rowId 的诊断,
不得合并、不得跳过。
这个划分背后的原则是:凡是能被机器校验的,都不要用自然语言说。 字段名对不对、枚举合不合法、数组空不空,Schema 和 Zod 都能判断,prompt 里写了也是浪费 token 并且引入版本不同步的风险;而”什么算失败""证据的边界在哪”是真正的语义判断,只有 prompt 能表达。
同一时间被大幅收窄的还有前文提到的语义别名归一化:模糊匹配、“找最像的枚举”、字段改名这类带猜测性质的修复被彻底删除,只保留一张可逐条审计的固定别名白名单和容器形态规整——白名单的边界画在哪里、为什么这样画,第四篇会专门展开。白名单之外的偏差不再被代码兜住,直接显式失败,进入单行修复流程,由模型在下一次请求里输出合法值。这看起来是”变得更严格”,实际上是让失败显式化:无边界地静默改写数据,你永远不知道有多少数据被改过、改得对不对;显式失败的问题只是多一次修复调用。前者腐蚀数据可信度,后者只花一点算力。
修复请求(repair request)也遵循同样的原则:修复 prompt 只说明”请针对这一行重新给出诊断,保持语义判断标准不变,注意校验错误提示的问题”,字段结构由随请求发送的同一份 json_schema 保证。主链路和修复链路发送完全相同的 response_format,协议天然同步。
六、行级归并:每个期望 rowId 恰好一个结局
Schema 保证了单条 item 的结构合法,但批量响应还有一层协议问题:响应里的行和输入行的对应关系。旧逻辑”整批 Zod 校验,一坏全坏”的解法显然不对,正确的问题是:给定输入的期望 rowId 集合,如何把一次响应归并成”每个期望 rowId 恰好一个结局”。
归并器的输入是原始响应(未校验的 JSON)和期望 rowId 集合,规则按行判定:
| 响应中该 rowId 的情况 | 归并结果 |
|---|---|
| 恰好一条,且通过 Zod 校验 | success,立即落库 |
| 恰好一条,但未通过 Zod 校验 | failure(可修复),保留原始 item 和精简校验错误作为修复上下文 |
| 出现多条 | failure(可修复),不采用其中任何一条 |
| 完全没出现 | failure(可修复),错误类型 missing |
| 出现了期望集合之外的 rowId、缺失 rowId、非正整数 rowId | 只记为响应级协议异常,附加到相关 missing 行的修复上下文,不生成不存在的失败行 |
两个细节值得强调。
第一,遍历输入而不是遍历响应。归并的循环主体是”期望 rowId 集合”,响应只是被查表的对象。这样即使模型返回了 rowId: 999 这种输入里根本不存在的行,也不会凭空在数据库里造出一条 999 的失败记录——它只会作为异常信息附加到 missing 行的上下文里。失败永远指向真实存在的输入行。
第二,重复行一条都不采信。同一 rowId 返回两条内容不同的诊断时,没有任何可靠办法裁决哪条是真的,两条全弃、该行进入修复。这比”取第一条”慢一次调用,但避免了用错误的静默裁决污染统计。
归并之后是两阶段持久化,解决”单条异常放大”的另一半:
阶段一:初次批量响应归并完成
→ 立即保存所有 success 行(状态 COMPLETED)
→ 数据库真实状态重算任务计数
阶段二:对每个 failure 行并发受控地发起单行修复
→ 修复成功 → 保存为 COMPLETED
→ 修复失败 → 保存为最终失败,只影响该行
落库保持幂等:已经 COMPLETED 的行不会被后续失败覆盖。这样一来,即使修复请求超时、甚至 worker 进程在修复中途被杀掉,第一阶段已经验证通过的同行诊断仍然完好地留在数据库里。旧的”整批处理完才落库”模式下,一次进程中断等于丢掉整批已经支付了 token 成本的有效结果——这是用数据库的真实状态做断点,而不是指望进程活得够久。
另外有一类错误无法按行拆分,属于批级错误:非法 JSON、根值不是对象、items 缺失或不是数组、请求超时或 HTTP 失败、模型拒绝、内容为空或被截断。批级错误不产生任何初次成功项,批次内每个期望 rowId 各自进入一次单行修复。单行修复只执行一次逻辑尝试,不递归修复自己的失败——否则一个系统性问题会引发指数级的重试风暴。
七、服务端防线:Zod strict 的坑与语义兜底
严格 Schema 并不意味着服务端可以裸奔。防线必须有,而且要独立于模型网关——网关是外部依赖,它的兼容性、它的实现细节,都不应该成为数据质量的最后一道闸。
服务端防线的主体是 Zod,而这里有一个值得单独讲透的坑:
普通的 z.object() 遇到额外字段,行为是”剥离”而不是”拒绝”。
const loose = z.object({ outcome: z.enum(["SUCCESS", "FAILURE"]) });
// 旧协议下,这样一条响应会怎样?
loose.parse({ outcome: "SUCCESS", row_id: 3, extra: "whatever" });
// 成功!返回 { outcome: "SUCCESS" },row_id 和 extra 被静默剥离
也就是说,在默认配置下,Zod 扮演的是”过滤器”角色:把认识的字段挑出来,不认识的扔掉。对于”我只想安全地读取两个字段”的场景,这是合理默认;但对于”我要校验一个协议”的场景,它是危险的——row_id 混进响应里时校验照样通过,你甚至不会在错误日志里看到任何痕迹。剥离不是拒绝,静默通过比报错更糟。
正确做法是 .strict():
const strict = z
.object({
outcome: z.enum(["SUCCESS", "FAILURE"]),
})
.strict();
strict.parse({ outcome: "SUCCESS", row_id: 3 });
// 抛错:Unrecognized key 'row_id'
.strict()(等价于 z.strictObject())把”未知字段”从可容忍变成错误。诊断契约里 item schema 和 envelope schema 都用了 strict object,服务端从此不再静默接受任何协议之外的字段。这个坑的隐蔽之处在于:升级严格 Schema 之前,Zod 校验”看起来一直在工作”——它确实在工作,只是它当时的工作方式是过滤而不是守门。
Zod 承担的第二类职责,是 JSON Schema 严格子集表达不了的语义约束。例如”observedFacts 不得记录不可见内部根因”:
const observableFactSchema = z
.object({
fact: z.string().trim().min(1),
})
.strict()
.refine(value => !internalRootCausePattern.test(value.fact), {
message:
"observedFacts must describe visible dialogue facts, not internal root causes",
});
这类语义边界写不进 JSON Schema,所以留在 Zod,作为结构约束之外的语义防线。两层防线的分工:
| 防线 | 载体 | 覆盖的约束 |
|---|---|---|
| 第一道 | 严格 JSON Schema(网关) | 字段集合、类型、枚举、整数下限、key 正则、minItems |
| 第二道 | Zod strict(服务端) | 同样的结构约束(防网关失效)+ 语义 refine(证据边界等) |
两道防线缺一不可:只有第一道,网关兼容性问题或实现差异会直接放脏数据进来;只有第二道,模型输出的大量低级格式错误全部变成修复调用,成本和延迟都会显著上升。第一道把协议错误的数量压到接近零,第二道保证漏网之鱼一条也进不了库。
八、踩坑清单
这次改造过程中确认过的坑,按杀伤力排序:
- 普通
z.object()剥离而非拒绝额外字段。 最隐蔽的一个,详见上一节。凡是承担”协议校验”职责的 schema,都应该用.strict()。 - prompt 承担字段协议。
row_id/rowId漂移、枚举混用的根源。任何写在 prompt 里的结构性约束,都要做好它被违反的准备,并且要问自己为什么不用 Schema 表达。 - 修复链路协议不同步。 修复 prompt 里的枚举列表和主 prompt 不一致,修出来还是坏的。修复请求必须发送与主请求完全相同的
json_schemaresponse_format,词汇表从同一份常量派生。 - 整批校验放大单条异常。
z.array(itemSchema)一次 parse 整个数组,一条坏行等于整批失败。批量响应要先做行级归并,再逐行校验、逐行定结局。 - 无边界归一化掩盖协议问题。 “找最像的枚举”这类猜测式映射让失败率好看,也让协议边界永远说不清。把归一化收窄成可审计的固定白名单、白名单之外的偏差显式进入修复,短期失败率上升,长期协议稳定。
- 遍历响应而不是遍历输入。 以响应行为主循环,模型幻觉出的 rowId 会变成数据库里的幽灵失败行。以期望 rowId 集合为主循环,失败永远指向真实输入。
- 全部处理完才落库。 批处理中途失败等于丢掉全部已付费的有效结果。分阶段持久化 + 幂等写入,让数据库状态成为断点。
- 错误信息带出完整响应。 校验错误摘要里只放精简的字段级错误,不落完整对话内容和完整模型响应,避免日志变成数据泄露面。
九、局限与适用边界
严格 Schema 不是免费的,它有明确的表达力边界和生态约束。
严格子集的表达力是有限的。 OpenAI Structured Outputs 的严格模式支持的是 JSON Schema 的一个子集:不支持的条件逻辑(如”outcome 为 FAILURE 时 failureModes 必须非空”这种字段间约束)写不进去,minItems 支持但跨字段的联合约束不支持,复杂的 oneOf/anyOf 组合在部分网关上有兼容性差异。所以协议设计的原则是”结构进 Schema、语义进 Zod”,不要试图把所有校验都塞进 Schema 而扭曲它的形态。
网关兼容性需要实测。 “OpenAI 兼容”网关对 json_schema + strict: true 的支持程度参差不齐:有的完整支持,有的会静默降级为普通 JSON 模式,有的对特定关键字的解释不一。我们的做法是:单元测试验证请求序列化和本地契约,另外保留一个只在配置了真实网关凭证时才手动运行的 smoke test,专门验证关键约束的实际生效情况。上线前跑一次,比上线后从坏行里发现降级便宜得多。
它解决协议,不解决语义。 Schema 能保证 severity 一定是四个枚举值之一,不能保证这个 P1 判得对不对、证据是不是真的来自对话原文。语义质量靠的是第一篇讲过的诊断工作流设计(证据引用、逐行对应),以及后续报告篇会讲的人工抽样复核。不要期待一个 Schema 能替你判断模型说得对不对。
适用边界。 这套方案适合”批量、有明确契约、下游是程序化消费”的 LLM 调用——诊断、分类、抽取、评估这类任务。对于面向人的自由文本生成,严格 Schema 没有必要也没有意义。另外,单次批量的大小要克制:一次请求诊断的行数越多,单次失败重试的代价越大;批次太小又会浪费请求开销,这个平衡点与具体模型和网关相关,值得用真实负载测一测。
十、总结
这一篇的核心命题可以压缩成一句话:模型负责语义判断,应用负责协议边界。
整个改造就是不断把职责放回正确的位置:字段集合、枚举、数值边界从 prompt 挪到 JSON Schema;词汇表从多处副本收敛为一份共享常量;格式指令从 prompt 删除,换成语义定义和证据边界;整批校验换成行级归并;静默归一化换成显式失败加一次修复;服务端用 strict Zod 兜住网关覆盖不到的语义约束。做完这些之后,prompt 变短了,代码变多了,但”输出不合法”从一类需要人盯着的线上问题,变成了一个有固定处理路径(单行修复)的普通工程事件。
协议层稳住之后,剩下的自然问题是:模型输出仍然会有不完美的时刻——拒绝生成、长度截断、单行修复也失败。这些情况下如何归一化、如何隔离失败、如何不让局部问题拖垮全局,是下一篇的内容。