写不清楚,通常是没想清楚
架构师的时间有很大一部分花在写和讲上:立项材料、方案评审、跨团队契约、故障复盘、路线图说明。写得长不等于写得清楚,讲得多不等于讲明白了。
一个常见场景:方案文档写了二十页,管理层看完只问“要花多少钱、什么时候能上线”;团队看完只问“接口是什么、谁先改”;跨团队看完只问“边界在哪、出问题谁负责”。同一份文档,三类读者找不到各自要的答案。
问题不在信息量,而在结构。这一节给一套可检查的写作结构:先定读者,再定请求,最后才组织证据。文末的 internal/archwriting 把这套结构变成校验规则和行动项提取器。
三类读者,三种第一问题
| 读者 | 第一问题 | 要什么 | 不要什么 | 典型篇幅 |
|---|---|---|---|---|
| 管理层 | 要花多少,风险多大 | 结论、成本、风险、请求 | 协议细节、代码路径 | 1 页 |
| 执行团队 | 接口是什么,谁先改 | 契约、里程碑、依赖 | 战略叙事、重复背景 | 3–5 页 |
| 跨团队 | 边界在哪,谁负责 | 契约、SLA、责任矩阵 | 内部实现细节 | 2–3 页 |
读者分层不是把人分等级,而是把问题分类。管理层不是不懂技术,而是不需要用技术细节做决定;团队不是不关心成本,而是成本不该由他们从二十页里自己找。
一份文档可以同时服务三类读者,但要有明确的主读者。主读者决定结论的写法和请求的形式,其他读者通过附录和链接获取细节。试图让所有人都满意,通常会让所有人都找不到重点。
结构:结论先行,请求收尾
| 段落 | 回答的问题 | 长度 | 常见错误 |
|---|---|---|---|
| 结论 | 建议做什么,为什么现在做 | 3–5 句 | 从背景讲起,结论埋在最后 |
| 证据 | 凭什么这么说 | 数据、压测、故障记录 | 只放观点,不放证据 |
| 选项 | 还考虑过什么 | 2–4 个,含“不做” | 只写推荐方案 |
| 后果 | 每个选项的代价 | 成本、风险、不可逆性 | 只写好处 |
| 请求 | 需要谁在什么时候做什么 | 一个明确动作 | 请求模糊或没有截止日 |
结论先行不是把答案甩出来,而是先给读者一个可以反驳的靶子。没有结论的文档,读者会在阅读过程中不断猜测“所以呢”,注意力被消耗在猜意图上。
选项部分必须包含“不做”。很多方案的真实替代项不是另一个架构,而是维持现状。把“不做”写进表格,才能让代价比较完整。
请求必须是一个动作,而不是一个愿望。“请支持这个方案”不是请求,“请批准 40 万元预算,负责人 @老陈,2026-11-20 前确认”才是。请求要写清谁、做什么、什么时候、不做的后果。
一个可复用的骨架
| 段落 | 示例 |
|---|---|
| 结论 | 建议采用异步补偿,Q4 上线,预算 40 万元(示例值)。 |
| 证据 | 近 30 天对账差分 0.03%(示例值),集中在跨区高峰;同步重试会把结算阻塞放大 3 倍(示例值)。 |
| 选项 | A 同步重试;B 异步补偿;C 人工对账;D 不做。 |
| 后果 | B 增加一条补偿链路;A 风险最高;C 人力不可持续;D 差分继续累积。 |
| 请求 | 请批准预算,负责人 @老陈,2026-11-20 前确认。 |
这个骨架不限制文风,只限制信息顺序。技术细节可以放在证据的附录里,但不能改变“结论—证据—选项—后果—请求”的主干。
会议与评审:时间盒、留痕、异步优先
评审会最常见的失败,是把“评审”开成“宣讲”。主讲人讲四十分钟,剩下十分钟提问,最后没有人明确反对,也没有人明确同意。
| 规则 | 做法 | 目的 |
|---|---|---|
| 时间盒 | 主讲 15 分钟,提问 20 分钟,决策 10 分钟 | 防止细节吞噬决策 |
| 预读 | 会前 24 小时发文档,会上不逐页讲 | 把时间留给分歧 |
| 反对留痕 | 记录反对者、理由、是否被采纳 | 避免“当时没人反对” |
| 决策记录 | 会后 24 小时内发结论与行动项 | 防止结论漂移 |
| 异步优先 | 能书面解决的不到会 | 降低协调成本 |
反对意见要记录“理由”和“是否被采纳”,而不是只记“有不同意见”。半年后复盘时,最有价值的信息往往是“当时为什么否决了那个后来出问题的选项”。
会议结束前要留三分钟做三件事:复述结论、确认行动项、确认下一次检查点。没有行动项的评审会,通常等于没有发生。
可运行片段:把结构检查变成代码
archwriting.go 做三件事:按读者列出必需段落、检查文档结构与请求完整性、从清单里提取行动项。
// file: internal/archwriting/archwriting.go
// tier: L1
// verify: go test ./internal/archwriting/
package archwriting
import (
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
)
type Audience string
const (
Executive Audience = "executive"
Team Audience = "team"
CrossTeam Audience = "cross-team"
)
type Kind string
const (
Conclusion Kind = "conclusion"
Evidence Kind = "evidence"
Options Kind = "options"
Consequences Kind = "consequences"
Request Kind = "request"
Cost Kind = "cost"
Risk Kind = "risk"
Interfaces Kind = "interfaces"
Milestones Kind = "milestones"
Contract Kind = "contract"
Boundary Kind = "boundary"
)
type Section struct {
Heading string
Body string
}
type Document struct {
Title string
Audience Audience
Sections []Section
}
var aliases = map[Kind][]string{
Conclusion: {"结论", "conclusion", "decision"},
Evidence: {"证据", "数据", "evidence"},
Options: {"选项", "备选", "options", "alternatives"},
Consequences: {"后果", "影响", "consequences", "impact"},
Request: {"请求", "需要", "ask", "request"},
Cost: {"成本", "预算", "cost", "budget"},
Risk: {"风险", "risk"},
Interfaces: {"接口", "interface", "api"},
Milestones: {"里程碑", "计划", "milestone", "timeline"},
Contract: {"契约", "协议", "contract"},
Boundary: {"边界", "职责", "boundary", "ownership"},
}
var baseKinds = []Kind{Conclusion, Evidence, Options, Consequences, Request}
// Required returns the section kinds a document must contain for an audience.
// The base five answer "what do you want me to decide"; the audience extras
// answer the questions that audience will ask first.
func Required(a Audience) []Kind {
out := append([]Kind{}, baseKinds...)
switch a {
case Executive:
out = append(out, Cost, Risk)
case Team:
out = append(out, Interfaces, Milestones)
case CrossTeam:
out = append(out, Contract, Boundary)
default:
return nil
}
return out
}
func normalize(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// classify returns every kind a heading mentions, so "接口与边界" satisfies
// both requirements instead of forcing authors to split one idea in two.
func classify(heading string) map[Kind]bool {
h := normalize(heading)
out := make(map[Kind]bool)
for k, names := range aliases {
for _, n := range names {
if strings.Contains(h, strings.ToLower(n)) {
out[k] = true
break
}
}
}
return out
}
type Finding struct {
Severity string
Code string
Message string
}
func errorf(code, format string, args ...any) Finding {
return Finding{Severity: "error", Code: code, Message: fmt.Sprintf(format, args...)}
}
func warnf(code, format string, args ...any) Finding {
return Finding{Severity: "warning", Code: code, Message: fmt.Sprintf(format, args...)}
}
var datePattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
// Validate checks structure, audience fit, and the request section. It reports
// every finding instead of stopping at the first one so an author can fix the
// document in one pass.
func Validate(doc Document) []Finding {
var fs []Finding
if strings.TrimSpace(doc.Title) == "" {
fs = append(fs, errorf("empty-title", "document has no title"))
}
if len(Required(doc.Audience)) == 0 {
fs = append(fs, errorf("unknown-audience", "unknown audience %q", doc.Audience))
}
if len(doc.Sections) == 0 {
fs = append(fs, errorf("empty-document", "document has no sections"))
return fs
}
first := classify(doc.Sections[0].Heading)
if !first[Conclusion] {
fs = append(fs, errorf("conclusion-not-first", "first section %q is not a conclusion", doc.Sections[0].Heading))
}
present := make(map[Kind]bool)
for _, s := range doc.Sections {
kinds := classify(s.Heading)
if len(kinds) == 0 {
fs = append(fs, warnf("unknown-heading", "heading %q maps to no known kind", s.Heading))
}
if strings.TrimSpace(s.Body) == "" {
fs = append(fs, errorf("empty-section", "section %q has no body", s.Heading))
}
for k := range kinds {
if present[k] {
fs = append(fs, warnf("duplicate-kind", "kind %s appears more than once", k))
}
present[k] = true
}
}
for _, k := range Required(doc.Audience) {
if !present[k] {
fs = append(fs, errorf("missing-section", "missing required section %s for %s", k, doc.Audience))
}
}
if first[Conclusion] && utf8.RuneCountInString(doc.Sections[0].Body) > 600 {
fs = append(fs, warnf("long-conclusion", "conclusion is %d runes; put detail in evidence",
utf8.RuneCountInString(doc.Sections[0].Body)))
}
for _, s := range doc.Sections {
if !classify(s.Heading)[Request] {
continue
}
body := s.Body
if !strings.Contains(body, "负责人") && !strings.Contains(strings.ToLower(body), "owner") &&
!strings.Contains(body, "@") {
fs = append(fs, warnf("request-without-owner", "request has no owner marker"))
}
if !datePattern.MatchString(body) {
fs = append(fs, warnf("request-without-date", "request has no YYYY-MM-DD date"))
}
}
return fs
}
type Action struct {
Text string
Owner string
Due string
Done bool
}
var (
actionPattern = regexp.MustCompile(`^-\s*\[( |x|X)\]\s*(.+)$`)
ownerPattern = regexp.MustCompile(`@([^\s]+)`)
duePattern = regexp.MustCompile(`due:(\d{4}-\d{2}-\d{2})`)
)
// ExtractActions reads "- [ ]" checklist lines from every section. Owner and
// due date are mandatory: an action without either is a wish, and the parser
// returns an error rather than silently dropping it.
func ExtractActions(doc Document) ([]Action, []error) {
var out []Action
var errs []error
for _, s := range doc.Sections {
for _, line := range strings.Split(s.Body, "\n") {
m := actionPattern.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
continue
}
a := Action{Text: m[2], Done: m[1] != " "}
if o := ownerPattern.FindStringSubmatch(a.Text); o != nil {
a.Owner = o[1]
a.Text = strings.Replace(a.Text, o[0], "", 1)
}
if d := duePattern.FindStringSubmatch(a.Text); d != nil {
a.Due = d[1]
a.Text = strings.Replace(a.Text, d[0], "", 1)
}
a.Text = strings.Join(strings.Fields(a.Text), " ")
if a.Owner == "" {
errs = append(errs, fmt.Errorf("action %q: missing @owner", a.Text))
}
if a.Due == "" {
errs = append(errs, fmt.Errorf("action %q: missing due:date", a.Text))
} else if _, err := time.Parse("2006-01-02", a.Due); err != nil {
errs = append(errs, fmt.Errorf("action %q: bad due date %q", a.Text, a.Due))
}
out = append(out, a)
}
}
sortActions(out)
return out, errs
}
func sortActions(as []Action) {
for i := 1; i < len(as); i++ {
for j := i; j > 0; j-- {
if as[j].Due > as[j-1].Due || (as[j].Due == as[j-1].Due && as[j].Text >= as[j-1].Text) {
break
}
as[j], as[j-1] = as[j-1], as[j]
}
}
}
代码里有四个设计取舍。
第一,Required 先给五段基础结构,再按读者追加。管理层追加成本与风险,团队追加接口与里程碑,跨团队追加契约与边界。基础结构保证“结论—证据—选项—后果—请求”不被读者类型冲掉。
第二,标题用别名匹配,而不是固定字符串。接口与边界 同时满足接口和边界两个要求,作者不需要为了通过校验把一个小节拆成两个。
第三,Validate 返回全部问题,不在第一个错误处停止。文档评审通常一次改多处,逐条报错比只报第一条更省时间。
第四,行动项必须有 @负责人 和 due:日期。缺一个就报错,因为“有人会跟进”不是行动项。提取时会把负责人和日期从正文里移除,避免同一信息在标题和元数据里重复。
校验器不判断内容对不对。它只能告诉你“缺少风险段”“结论不在第一段”“请求没有截止日”,不能告诉你风险写得是否诚实。内容判断仍然要人来做。
失败模式
| 失败模式 | 表现 | 兜底 |
|---|---|---|
| 背景先行 | 前三页都在讲历史 | 把结论提到第一段 |
| 只写推荐方案 | 没有选项与“不做” | 强制列 2–4 个选项 |
| 证据与结论脱节 | 数据很多,推不出结论 | 每条证据标注支持哪个结论 |
| 请求模糊 | “请支持”没有动作与日期 | 写成谁、做什么、何时 |
| 反对意见消失 | 会上没人反对,会后有人抱怨 | 记录反对者与理由 |
| 会议无行动项 | 开完没有变化 | 结束前确认行动项 |
| 文档不更新 | 决策变了,文档还是旧的 | 文档与决策记录互相引用 |
| 一份文档服务所有人 | 谁都找不到重点 | 明确主读者,其余放附录 |
检查表
- 文档开头三句内给出结论与请求。
- 主读者明确,其他读者的细节放在附录或链接。
- 证据能逐条对应结论,而不是堆数据。
- 选项包含“不做”,并写出每个选项的代价。
- 请求写清谁、做什么、什么时候、不做的后果。
- 评审会前 24 小时发预读材料。
- 会议记录包含反对者、理由、是否被采纳。
- 每个行动项都有负责人和截止日期。
- 文档与相关决策记录互相引用。
- 决策变化后 24 小时内更新文档状态。
小结
架构沟通不是把技术讲得更通俗,而是把读者要做的决定、需要的证据、可选的路径和明确的请求按顺序摆好。结论先行让读者有靶子,证据在后让结论可检验,选项与后果让取舍可见,请求让文档产生动作。
internal/archwriting 把这套结构变成可执行检查:缺段、结论位置、请求完整性、行动项格式。它不能替架构师判断,但能防止一份想清楚的文档因为结构混乱而没有被读懂。
下一章进入全卷收口:用四卷的案例回看从品类到平台的路径,并把治理责任交还给产品团队与平台团队。
阅读导航:上一节:12.2 技术选型与供应商评估 · 下一节:13.1 四卷回顾:从品类到平台 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。