16诚实的实测、边界与练习
把增量收益与 Actor 税分开计量;五准则对照;何时不要用 Actor 解析;六个可验收练习。
上一章把「插件机制是真的」钉死在测试上,并许下一个承诺:把增量渲染的收益和 Actor 架构本身的税分开计量,给一份诚实的边界与可验收的练习。这一章兑现承诺,也是全书最后一章——不再引入新机制,只做四件事:用基准把两笔账算清楚,把结论泛化成一套能复用到任何「X 架构解析器」宣称上的准则,画一条「何时不该用 Actor 解析」的明确边界,再留几道练习收尾——越往后的题目越会碰到运行时本身,而不只是解析规则。
实测:把架构收益与架构税分开计量
公平比较的前提,是把两个正交的变量分开:增量架构(状态机 vs 全量重解析)与 Actor 化(消息传递 vs 函数调用)。bench_test.go 用一组基准把它们拆开,全部基于同一份夹具:benchDoc 生成一篇 100 块的混合文档(标题、带行内标记的段落、围栏代码、列表循环出现),约 4.4KB;每个基准都把它切成相同的 64 字节 chunk 喂入——流式 LLM 吐字的常见粒度。跑在 Apple M4 Pro 上,并已叠加下文的内核优化。
// benchDoc 生成 blocks 个块的混合文档(标题/段落/代码/列表循环出现)。func benchDoc(blocks int) string { var sb strings.Builder for i := range blocks { switch i % 4 { case 0: sb.WriteString("## Section " + strconv.Itoa(i) + "\n\n") case 1: sb.WriteString("Paragraph *with* **inline** `markup` and [a link](https://example.com/p) number " + strconv.Itoa(i) + ".\n\n") case 2: sb.WriteString(fence("go", "func f"+strconv.Itoa(i)+"() int {\n\treturn "+strconv.Itoa(i)+"\n}\n") + "\n") case 3: sb.WriteString("- item one\n- item two\n- item three\n\n") } } return sb.String()}| 基准 | 每篇耗时 | 分配次数 | 相对基线 | 它在测什么 |
|---|---|---|---|---|
SequentialBatch | 52 µs | 412 | 1× | pull 式单线程批量——性能天花板 |
NaiveRestreamPerChunk | 1.9 ms | ~15000 | ~37× 慢 | 聊天 UI 朴素做法:每 chunk 全量重解析,O(n²) |
IncrementalNoActors | ~57 µs | 487 | 1.1× | 同一状态机、单线程——增量几乎免费 |
ActorStreamFusedBulk | 89 µs | 1445 | 1.7× | 融合单 Actor 内解析(一次喂入、无订阅) |
ActorStreamFused | 273 µs | 2312 | 5.2× | 融合流式(逐 chunk 进、逐事件出) |
ActorStreamNoSpawn | 585 µs | 3593 | 11× | 每块扇出 worker 的流水线 |
三个诚实的结论,缺一都会让比较失真:
-
O(n²)→O(n)的收益来自增量状态机,不来自 Actor。 朴素重流式每 chunk 把累积字符串从头重解析,总成本近似正比于文档长度的平方——表里 ~37× 的差距,且随文档变长继续拉大。这份收益在IncrementalNoActors就已全部兑现,与 pull 基线几乎没有差别。只要增量,一个单线程状态机就够,不用 Actor,也不花钱。 -
那「6× 税」不是 Actor 固有的,是「每块一跳」的粒度税。 对扇出流水线做 CPU profile,热点几乎全是
runtime.selectgo、usleep、park_m、schedule、lock2、runqsteal——goroutine 的 park/wake 与 channel 锁竞争,几乎没有解析本身。根因是每个块要跨两次 goroutine(doc → worker → assembler),而每块真实工作只有几微秒:一次 goroutine 交接比它承载的活儿还贵。这是实现的粒度选择,不是模型的性质。 -
把 Actor 边界对准「隔离单元」(会话/文档)而非「解析步」,税立刻塌下去。 每块扇出是教学用的演示(展示并行行内 + 重排);真实单文档流式应当融合——一个 Actor 内以函数调用跑完块级 + 行内 + 渲染 + 有序输出,Actor 边界只留在
Feed/Subscribe,那里它才真正值钱(会话隔离、背压、重放)。实现见WithInlineWorkers(0),分解如下:ActorStreamFusedBulk= 在一个 Actor 内解析,1.7×,残差只是 spawn 与一次Ask——Actor 对解析本身几乎零开销。1.7× → 5.2×纯粹是流式 I/O(逐 chunk 进 + 逐事件出跨 channel)。基准全速喂入时它像成本,生产里被 LLM 出词延迟(几十 ms/chunk)完全盖住——一次 channel 操作约 100 ns。5.2× → 11×才是每块扇出的 goroutine 乒乓,只有当行内工作足够重、或你确实需要每块独立故障域时才值得付。
⚠️ 当心
这些倍数比本书早期版本更大(如融合从 1.4× 涨到 1.7×、扇出从 7.6× 涨到 11×),但不是 Actor 变慢了——恰恰相反。下文的内核优化把 pull 基线从 79 µs 压到了 52 µs,而 Actor 各路径的绝对耗时几乎没动(
FusedBulk甚至从 113 µs 降到 89 µs)。分母变小,比值自然变大:倍数永远是「相对一个不断变快的基线」的相对量,单看比值会把「基线进步」错读成「税变重」。这本身就是一条方法论警告——报告开销比时,必须同时给出基线的绝对值。
// fusedDocActor 把整条流水线塌缩进一个 Actor:块级状态机、行内解析、渲染、有序输出、// 订阅推送全部以函数调用在本 Actor 内完成——没有 worker、没有独立 Assembler、// 没有任何跨 goroutine 的每块消息。profile 显示那 6× 的开销几乎全是 goroutine// park/wake + channel 锁竞争(每块两跳),这里一次性消除。//// 保留的 Actor 语义:Feed/Subscribe/Snapshot 仍是消息,因此会话隔离、背压(阻塞推送)、// 断线重放一个不少。放弃的:每块独立故障域(这里故障域是整篇文档)与行内并行——// 需要它们时用 WithInlineWorkers(n>=1) 的扇出版本。type fusedDocActor struct { cfg parserConfig bp *BlockState buf string stable strings.Builder // 已定稿 HTML(顺序产出,无需重排) frags []string // 各片段(供 Subscribe 从任意 seq 重放) provisional string subs []*subscription done bool closed bool}内核优化:更快,也更省内存
上面的数字已是若干轮 profile 制导优化之后的。做法始终一致——profile 指到哪就打哪:值切片 item([]*inlineItem → []inlineItem)、惰性分隔符回退文本、块级事件缓冲 [:0] 复用、InlineState 池化、纯文本的无标记快速路径、leafStore 复用打开的叶子块。这些修改全部落在 doc / worker / fused 共享的纯逻辑内核上,因此每条路径都跟着受益。累计下来,纯 pull 解析从 ch14 交接时的约 101 µs / 2562 次分配,降到 52 µs / 412 次分配(耗时 −49%、分配 −84%);分配砍到不足五分之一,GC 压力随之骤降。这套优化连同它催生的极致单线程 pull API、以及把行内 AST 拍扁成事件流的改造,都是上一章(第 15 章「极致 Pull 模式」)的主题。
// PullParser 是一个可复用的单线程解析器:构建一次,解析多篇。type PullParser struct { cfg *Config}还能再压:两条没走完的路
💡 小贴士
下面两条是「工作方案」而非既成结论——留给读者(和练习)去落地与实测。(第三条「行内扁平 token 流」已在第 15 章落地:行内从带
Children的树改成开/闭令牌流,分配从 462 降到 412;但因扁平流令牌更多,字节数略升——它是架构一致性的胜利,而非性能优化。)
- 粗化粒度 + 段级并行:要真正超过 pull 的天花板而不只是接近,得用 pull 结构上拿不到的东西——多核。pull 单文档只能吃一个核;而 CommonMark 第二阶段(行内)按块独立、天然可并行。把大文档在安全边界(顶格空行、围栏之外)切成段,每段一个 Actor 并行解析再拼接——当「每单元工作 × 单元数」压过协调成本(大文档 + 重行内,如语法高亮 / LaTeX)时,Actor 版就能在墙钟上赢过 pull。当前每块工作太轻(几微秒),协调盖过收益,所以这里不假装它赢了。
- 零拷贝载荷:消息载荷传
(start, end)索引而非拷贝子串,把行内解析的输入也变成对原文的零拷贝视图——进一步压低大文档的内存峰值。
五准则:没有全赢的架构
把上面两条结论泛化,就是一套可以复用在任何「X 架构解析器」宣称上的准则:批量吞吐、首字延迟(TTFR)、追加 chunk 的增量成本、慢消费者下的内存上界、故障时的损失半径。每一条都对应一种真实存在的压力:TTFR 是聊天界面用户能切身感受到的东西,内存上界决定一个迟钝的移动端消费者会不会把共享服务器拖垮,损失半径区分的是「一个坏请求」和「一个坏租户」。把本书出现过的四种架构摆进同一张表,赢家从来不是同一个:
| 准则 | pull 解析器 | naive 全量重解析 | 增量状态机 | Actor 拓扑 |
|---|---|---|---|---|
| 批量吞吐 | 赢——无调度开销,52 µs 基线 | 差,O(n²) 随长度恶化 | 持平于 pull,~57 µs | 融合 1.7×、扇出 11×——由粒度而非模型决定 |
| 首字延迟(TTFR) | 差——须等整篇解析完才有输出 | 能逐 chunk 出结果,但每步重算全量 | 赢——状态机天然逐 chunk 产出 | 同增量,叠加跨 Actor 调度 |
| 追加 chunk 的增量成本 | 不支持追加 | 差,正比于累计长度 | 赢——只处理新增字节 | 同增量,叠加消息投递 |
| 慢消费者下的内存上界 | 无此概念,单次调用即释放 | 无界,每次全量重算 | 有界,但与调用方节奏耦合 | 赢——mailbox 背压逐级反向传播 |
| 故障时的损失半径 | 一崩全崩 | 一崩全崩 | 一崩全崩,单线程状态机 | 赢——单块崩溃不连坐整篇 |
🔑 设计钥匙
没有一种架构能在五条准则上全赢:pull 解析器赢批量吞吐,增量状态机赢首字延迟与追加成本,Actor 拓扑赢慢消费者内存与故障半径。选架构不是选「更先进」的那一个,而是把场景的真实约束——要不要流式?要不要多会话隔离?消费节奏是不是你控制不了的?——对应到表里那一列。
何时不要用 Actor 解析
诚实清单,反着读也成立——它同时是一份「什么时候才该用 Actor 解析」的清单:
- 单文档批量转换:构建静态站点、CI 里渲染一份 README、Pandoc 风格的转换管道——pull 解析器仍是无可争议的王道,零调度开销、最低内存、调用方最容易推理的错误传播路径。给
cmark或goldmark套一层 Actor 来转换单个文件,是行为艺术,不是工程——这里既没有需要庇护的慢消费者,也没有需要彼此隔离的第二个会话。 - 纯库交付:库的消费者不想继承你的运行时、你的调度器、你的监督树。pulldown-cmark 的 pull 接口之所以好用,正因为它把迭代节奏、内存、取消全部留给调用方——调用方多快拉一次下一个事件,压根不是库该管的事。而 Actor 形态的 API,不管消费者愿不愿意,都会把恰恰相反的约束强加给对方。
- 行内解析真的成为单文档瓶颈时:先怀疑实现,再考虑并行。52 µs/4.4KB 的 pull 基线留出了巨大的余量——常规文档根本轮不到并行行内 worker 出场,它们连自己的调度开销都赚不回来,更别说跑赢。先做 profiling;一个单文档要烧掉两位数毫秒的解析器,更可能是分配问题而不是并发问题,而 Actor 对两者都无能为力。
- 语法完整性优先于流式性时:第 11 章的链接引用屏障、松散列表前瞻都表明,全量 CommonMark 与纯流式之间存在真实张力。tree-sitter-markdown 的维护者把大量不准确归因于「Markdown 的上下文敏感性」超出了语法框架能形式化的能力范围,并明确不建议在正确性敏感场景使用——这条未经独立核实,但与本书第 11 章砍语法子集的动机互为印证:Markdown 对一切「框架化」解析都不友好,流式框架也不例外。
Actor 解析的主场,就是第 1 章那个场景:多会话、流式输入、慢消费者、要求局部故障不扩散的在线服务——恰好就是 AI 应用后端的形状。
练习
难度递增,靠后的题目会碰到运行时本身,而不只是解析规则。
- 图片(新增一条
InlineRule):与链接高度同构,触发字符!,产出KindLink或一个KindCustom的img节点并注册渲染。启用TestExerciseImage。入门题——照着第 14 章ext_gfm.go里的strikeRule抄,改一下触发字符和节点形状即可。 - Setext 标题(新增一条
LeafRule):Title\n===→<h1>。在其Open里回看s.leaf是否为单行打开的段落。启用TestExerciseSetextHeading。注意---与分割线的优先级冲突正是 CommonMark 有名的歧义点——你注册规则的顺序决定谁先赢。 - 惰性延续(改
paragraphRule或加判定):> a\nb要延续引用块段落。提示:matchPrefix失败,但存在打开段落、且本行无法开启任何新块时,不闭合容器而是延续段落。启用TestExerciseLazyContinuation,做完后重跑流批对拍——只有输入恰好整篇到达才生效的「惰性延续」不算真的修好,对流式分块不敏感才算数。 - 毒丸停止:上面的基准方法论已经提前露出了它为什么重要——运行时目前没有公开的「停止单个 Actor」API,长服务里跑完的文档树会一直存活。给
actor包加PID.Stop()(走系统消息通道而非业务 mailbox,想想为什么),再给Parser加Stop(),用/debug/actors验证子树回收。 - 链接引用表:实现第 11 章的「暂定 + 补丁」方案——新增一个
revision{seq, html}事件,在自己的笔记里衡量它对下游协议复杂度的冲击。引用表是跨块状态,Config单靠规则解决不了,这题是理解「规则注册表边界」的最好练习。 - 纯文本 Renderer:实现一个
Renderer,把文档渲染成去除标记的纯文本(给 LLM 输出做字数统计或摘要预处理)。SetRenderer换上即可复用整条流水线不变——证明第 14 章的渲染抽象确实把输出格式和解析彻底解耦了。 - 指标接入:给流水线加
mdparser_blocks_closed、mdparser_gap_healed、mdparser_provisional_updates三个计数器,在/metrics里观察一次流式会话的完整曲线。
💡 小贴士
前三道练习都能通过第 14 章的规则注册表落地——新增
InlineRule/LeafRule,或调整既有规则的判定逻辑,ext_gfm.go的删除线就是可以照抄的模板。链接引用表更进一步,探到了规则注册表本身的边界:跨块状态,规则机制单靠自己管不到。纯文本 Renderer 换的是渲染接口而不是解析规则,但吃到的是同一份第 14 章的解耦红利。真正完全跳出插件机制之外的,只有毒丸停止(碰运行时本身)和指标接入(碰可观测性)。
延伸阅读
已核实——本书直接依赖、且已对照原文核实过的来源:
- CommonMark Spec — Appendix A: A parsing strategy:第 11 章整套 Actor 拓扑的立论依据——块级解析串行且可流式,行内解析可并行,链接引用表是二者之间的屏障。
- pulldown-cmark:pull 迭代器设计,以及它对 push 式回调接口的警告——第 10 章把这条警告当成本书门面设计必须遵守的红线。
- markdown-it — architecture.md:三条嵌套规则链与扁平 token 流,第 14 章的规则注册表与渲染分派表都是照着这个思路对齐的。
相关线索(未独立核实)——值得一读,但这里只当作引子,不当作已核实的事实:
- tree-sitter-markdown:维护者对 Markdown 上下文敏感性的自述,前文「语法完整性优先于流式性」一条已经引用过。
- Chrome — Render LLM responses:浏览器厂商官方指南,论证了对流式 LLM 输出做朴素全量重解析的代价——正是上文
NaiveRestreamPerChunk实测出来的那种失败模式。
小结
- 五个基准把两笔账拆开算清,而不是只算一笔:
O(n²)→O(n)的收益单靠增量状态机就已兑现,几乎不花钱(~57 µs 对 52 µs);Actor 税可以再拆成约 530 µs 的稳态消息传递成本,加上每篇约 40 µs、可随长会话摊销的 spawn/关停冷启动成本,买到的是故障隔离、背压、按会话状态与重放。 - 公平评估任何解析器架构,要看五条准则——批量吞吐、TTFR、增量追加、慢消费者内存、故障损失半径——而不是只看一个「快不快」的数字;没有全赢的架构,只有匹配眼前这个场景的架构。
- 边界和收益一样清楚:单文档批量转换、纯库交付、行内解析未真正成为瓶颈、语法完整性优先于流式性——这几种场景里,pull 解析器仍是更诚实的选择;明知如此还硬上 Actor,说明架构是被故事选中的,不是被场景选中的。
- 全书至此收官。第一部分从 AI 后端在流式、状态、错误处理上的三重结构性缺失出发;第二部分把 Actor 摆到 Go 的 CSP channel、Rust 的所有权纪律、Flink 的 keyed state 与信用背压旁边,看到的是同一个形状换了不同的名字;第三部分把这个形状从零手写出来——密封 mailbox 背后的私有状态、串行 run 循环、panic 恢复与 let-it-crash 监督,再叠加调度器与死信队列把它加固向生产可用,最终在一个三层监督树的流式会话 demo 里验证成立。第四部分把同一套骨架原封不动地搬进一个和聊天会话毫不相干的领域:CommonMark 自己的附录 A 画好了切缝,
DocActor成为串行心脏,一池InlineWorker做规范允许的并行工作,Assembler把它们的产出重新排序,再加上冻结重渲染、按 seq 重排、缺口自愈监督、全链路背压这四大机制,把这套拓扑焊成一台把「半句未完的话」当默认输入、而不是边界情况的解析器。这一章的数字,是对整趟旅程最后一次诚实核账:增量的收益从来与 Actor 无关,而 Actor 收的税,买到的正是全书十五章一路铺垫的那些性质——故障隔离、背压、重放、按 key 隔离。当你的问题是「持续产出、按 key 隔离、要求局部故障不扩散」,Actor 就是那个形状本身,而不是又一层框架。