《游戏服务端架构·平台化与架构治理》12.3 架构师的沟通与写作

架构文档写不清,通常不是文笔问题,而是没有区分读者和请求。本文按管理层、团队、跨团队三类读者拆解关注点,给出结论先行、证据在后、选项与后果、明确请求的写作结构,以及会议时间盒、反对意见留痕和异步优先的评审方法,并用 Go 校验文档结构与提取行动项。

写不清楚,通常是没想清楚

架构师的时间有很大一部分花在写和讲上:立项材料、方案评审、跨团队契约、故障复盘、路线图说明。写得长不等于写得清楚,讲得多不等于讲明白了。

一个常见场景:方案文档写了二十页,管理层看完只问“要花多少钱、什么时候能上线”;团队看完只问“接口是什么、谁先改”;跨团队看完只问“边界在哪、出问题谁负责”。同一份文档,三类读者找不到各自要的答案。

问题不在信息量,而在结构。这一节给一套可检查的写作结构:先定读者,再定请求,最后才组织证据。文末的 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 四卷回顾:从品类到平台 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查