# Peri Code 的流式渲染——LLM 打一个字，终端就出一个字

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。`curl -fsSL https://raw.githubusercontent.com/konghayao/peri/main/scripts/install.sh | bash`

流式渲染的挑战不只是"把字画出来"，而是控制节奏。LLM 输出速度不均匀——有时连续冒出一串 token，有时停顿几百毫秒。如果来一个 token 就重绘一次，突发时渲染跟不上输出，用户看到的是「停顿 → 突然跳出一大段」。

Peri Code 把这个问题拆成三个独立问题分别处理：什么时候提交内容、谁来做渲染计算、重复内容怎么跳过。

## 自适应分块：按节奏泄洪，不按帧率硬刷

流式渲染最难处理的不是平均速度，是突发。推理模型输出 reasoning 时，十几个 token 可能瞬间到达；下一批又停顿几百毫秒。如果按固定间隔刷新，遇到突发就积压。

Peri Code 在两种模式间动态切换，最小间隔 16ms（约 60 FPS）。

**Smooth 模式**是默认路径，每个 tick 最多提交一行，保证字一个一个出来的视觉节奏。LLM 正常输出时每秒约 50 个 token，在 80 列终端上大约产生 3-5 行，16ms 的间隔完全够用。

**CatchUp 模式**处理突发，触发条件有两个——队列积压超过 8 行，或者最老的一行等待超过 120ms。满足任意一个，立即把所有积压内容一次性清空。推理文本突发时用这个模式追上来，比让用户盯着内容缓慢追赶要好得多。

退出 CatchUp 的门槛刻意收紧，队列深度降到 2 行以下且最老行年龄低于 40ms，两个条件同时满足才切回 Smooth。单条件退出会导致模式在边界反复横跳，抖动本身就是一种卡顿。

没有新内容时不触发任何操作——队列空时直接返回，不更新缓存，不通知渲染端。

## 数据层全量重建，渲染层按 hash 复用

所有消息更新都走同一条路径——`messages_to_view_models()` 是唯一的转换入口，把 `BaseMessage` 列表转为 `MessageViewModel` 列表。每次更新都完整重建，不做增量 diff。

为什么不做数据层增量 diff？增量 diff 需要追踪变更状态、处理插入删除和中间消息修改，代码复杂度高。全量重建逻辑简单，关键在于渲染层通过 hash 复用来消化全量重建的代价。

重建后的 `MessageViewModel` 列表发给独立的渲染 task（运行在 tokio runtime 上）。渲染 task 对每条消息计算 hash，与上次对比——hash 没变的消息直接复用上次的渲染结果，跳过 Markdown 解析。流式输出期间，通常只有最后一条 assistant 消息在变化，前面几十条的 hash 完全稳定——只有一条消息真正触发重算，其余全部命中缓存。

hash 涵盖内容字段和影响渲染的 UI 状态（如 `is_streaming`、`collapsed`），不包含渲染缓存本身（因为渲染缓存依赖终端宽度）。此外还有一个 cosmetic change 检测：当消息的 hash 变了但实际显示内容没变（比如 `is_streaming` 从 true 变 false），也直接复用旧缓存。

换行计算同理。每个字符都要算显示宽度——ASCII 1 列、CJK 2 列——再精确计算视觉行数。前面消息的换行结果稳定复用，只有尾部新增的几行需要重新计算。

## 三种流式内容，三种处理策略

**普通文本**按换行符分行，走自适应分块控制提交节奏。流式期间做增量 Markdown 解析——找到上次渲染的稳定块边界（代码围栏、空行），只对新增部分调用完整解析器，支持代码高亮、链接、粗体等全部 Markdown 特性。这样做避免了"每来一个 token 就重解析整段"的线性成本，而是按块边界做固定成本的增量解析。本轮输出完成后，由 reconcile 重建 ViewModel 做一次全量解析确保最终结果正确。

**推理内容**（reasoning）单独累积，作为独立的 block 类型与正文隔离渲染，显示为折叠块。流式期间只累积字符数和原文，不做任何 Markdown 解析。

**工具调用参数**以碎片化形式到达，SSE 格式按 index 交错发送多个片段。Peri Code 在 LLM 适配层用 ToolCallAccumulator 按 index 累积碎片，流式结束后拼接成完整 JSON 再传给 UI 层。UI 层收到的永远是完整的工具调用——流式期间显示工具名和参数卡片（内容字段待填充），在数据层面就不存在未完成的 JSON。

## 渲染计算与终端绘制分离

渲染 task 在后台处理 Markdown 解析、样式计算、换行计算，结果写进缓存（`RenderCache`，通过 `RwLock` 共享）。终端绘制端每帧从缓存读取数据、画文字，不做解析和布局计算。终端绘制端同时还负责事件处理、状态管理等其他职责——"零计算"说的是渲染相关的计算不在终端绘制端执行。

自适应分块控制提交节奏，hash 复用跳过不变的消息，渲染 task 在后台消化计算——三管齐下，让流式输出在终端上既流畅又不浪费算力。

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
