学 AI 产品 · 专业 AI 产品经理播客第 3 章 · T3 解剖 OpenAI Codex:把安全观写进类型系统 · EP 20
第 3 章 · EP 20

流式输出怎么在终端两区之间定稿 · 两种安全视角:同一条命令,两套判词

时长 17:22音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 三节合辑1:07
1:07稳定区与活动尾巴2:58
4:05表格没闭合,整段扣住1:58
6:04两档速度,一帧原子1:32
7:37把架构决策写成 lint2:22
10:00散文会腐坏1:51
11:51两种安全视角1:26
13:18四道门,还有一道出站漏斗2:32
15:50可带走的原则1:31
解读全文

ep32 · 流式输出怎么在终端两区之间定稿 · 两种安全视角:同一条命令,两套判词

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版
模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
来源:xueai.miyang.cn(小山学堂 · 洛小山)

本集为三节合辑(素材 12144 字),已做取舍,只保留最能带走的部分。


一句话速览

模型按 token 往外推,终端却是一个写出去就改不了的字符网格。已经不会变的行交给 scrollback,还可能变的尾巴留在活动 cell,表格没闭合之前整段扣住。

统一主线:三节都在处理同一件事 —— 边界划在哪里。

节边界划错的后果
终端两区可变 vs 不可变列宽抖动、残影
架构决策人读 vs 机器查文字还在,对象已搬家
安全视角事前 vs 事后能回放但没能拦住

能力地图

第一节 · 终端两区

区域归属能否再改
稳定区(scrollback)交给终端,进程内无副本不可改,提交即固化
活动尾巴(active cell)留在进程内下一帧整段可换

三个指针同向移动:enqueued_stable_len(已入队行数)→ emitted_stable_len(已写入 scrollback 行数);活动尾巴从 enqueue 边界算起,不从 emit 边界算起 —— 否则排队未写出的行会在活动区重复出现。

出处:codex-rs/tui/src/streaming/controller.rs L1–36。

表格扣留状态机

状态触发尾巴预算
None普通散文0,行直接进稳定队列
PendingHeader上一行像表头、下一行未到从 header 起整段扣住
Confirmed表头 + delimiter 两行对上从 header 起整段扣住,直到 finalize
Raw 模式关闭扣留0,表格当纯文本流走

出处:table_holdback.rs L21–32;controller.rs L384–412。

第二节 · 三层门禁(有红灯 vs 只有散文)

规则检查方式失效表现
调用点参数注释Dylint 插件(当一次 rustc)合并前亮红
依赖锁文件CI合并前亮红
Markdown 里的路径无静默失效
800 行上限无(停在评审)静默失效

第三节 · 可拒绝的四道门 + 一道漏斗

门看什么行为
策略argvAllow / Prompt / Forbidden 三态取最严
审批人精确 key(完整 argv + cwd + 权限)
Guardian模型判断超时/坏输出关闸
OS 沙箱路径 + syscallmacOS SBPL / Linux bubblewrap+seccomp / Windows 受限令牌
出站代理(漏斗)域名403 + 错误头回给命令进程,循环继续

思路一 · 已经不会变的行,交给终端自己保管

问题

散文一行行往下长没问题;一旦开始吐表格,列宽每来一行就变一次,刚对齐的列被挤到下一列,上一帧的竖线还印在屏幕上。往上滚时历史和尾巴叠在一起。

网页换 DOM 时浏览器会保住滚动位置;终端往 stdout 写一个字,光标就往前走一格,没有回头改的余地。

提示1 · 没换行的 token 连尾巴都不更新

用户看见的最小时间单位是一行 Markdown source。半行表格先画出来,下一秒结构一对,列会立刻消失再长出来。未结束的 source 进缓冲,不能改可见尾巴。

出处:chatwidget/streaming.rs L489–492。

排查指引:遇到「流式输出卡住」,先问这一帧里有没有换行。没有换行是设计内行为,不是 bug。真正的问题在另一端 —— 模型长时间不吐换行时用户以为界面死了,此时需要的是心跳提示,不是改渲染逻辑。

思路二 · 表格没闭合,整段扣在尾巴里

机制

markdown 表加一行就能改所有列宽。表头若已按窄列冻进 scrollback,后面的长单元格没法回去改它。

扫描器只认 header + delimiter:

  • 上一行像表头、下一行还没到 → PendingHeader,乐观扣住;
  • 后面来的是普通散文 → 回到 None,那一行再进稳定队列;
  • 两行对上 → Confirmed,从 header 起整张表留尾巴直到 finalize。

误伤控制:一句 status | owner | note 看起来像表头,其实只是散文。因此要求两行对上才确认,只有一行时保持待定而非直接判成表。

提示2 · 两个容易漏的细节

  • sh 围栏里的竖线当代码,不触发扣留;
  • 连续多张表时,第一张没结束,后面的表也不提前定稿 —— 超长多表答案会在活动区堆到流结束。

为什么长期成立:列宽是全局量,局部追加会改已经画过的行。把整张未闭合的表留在可变区是这条约束的最小解。网页对应做法:表格节点在闭合之前不要拆进不可变 DOM 片段。


思路三 · 队列用两档速度,一帧里的写入要一起走

两档排水

档位行为进入条件
平滑档每 tick 出一行默认
追赶档一次抽空队列深度 ≥ 8 行,或最老一行 > 120ms
退出回平滑深度 ≤ 2、年龄 ≤ 40ms,且保持 250ms
再进入保护挡 250ms除非堆到 64 行或 300ms

策略不看这段文本是标题还是表格,只看队列深度和年龄。出处:streaming/chunking.rs L82–125。

提示3 · 一帧里的多处写入必须原子提交

历史行用转义序列写到 viewport 上方,界面库再画输入框和活动尾巴。拆成两次刷新,用户会先看见历史往上跳一截,输入框还停在旧位置。

因此 scrollback 插入与 viewport 绘制包进同一次 sync_update;双 buffer 只把变过的格子写出去;画回调必须画满整帧,少画一块终端就留下上一帧残字。出处:tui.rs L954–973。

共同点:不要用平均值对付两种压力,也不要把同一帧的多次写入拆开提交。


思路四 · 把架构决策写成 lint

问题

同一份 AGENTS.md 里:位置参数少了注释 → 本地命令红;改了 Cargo.toml 忘刷 Bazel 锁 → CI 红;第 35 行的路径 → 没有任何检查器。真实文件叫 connection_manager.rs,文档里那个带 mcp_ 前缀的名字是重命名后的残留。

提示4 · 检查顺序(可直接照抄)

  1. 只查本仓库 crate,std / tokio 直接放过;
  2. 注释从参数前的空隙、前 64 字节、参数文本自身三处找;
  3. 名字不对报 mismatch(错注释不再落到「没写」那条);
  4. 没写时,方法名等于唯一参数名就豁免(如 .enabled(false));
  5. 剩下的只拦匿名字面量 —— None、布尔、数字要写,字符串和字符放过。

出处:tools/argument-comment-lint/src/lib.rs L165–180、L261–274;CI 三平台各跑一次:.github/workflows/rust-ci.yml L164–187。

首选永远是改 API 让调用点自己能读;改不了 API 才允许 /*param_name*/。lint 是退路,不是首选。


思路五 · 散文会腐坏

同一份文档第 265 行:写着 v2.rs,当前是目录 v2/,下面拆成三十多个文件。文件靠近 800 行就要拆,拆了之后指南里的单文件路径没人改。

模块行数规则点名五个高频文件,四个已越过 800,一个贴着 900;chat_composer.rs 按行计 12859 行。仓库里没有数行数的命令 —— 行数能数,CI 不数。

提示5 · 先做便宜的检查

路径存在性本是最容易检查的:抽出反引号路径,对仓库根做存在性判断,二十行脚本的事。仓库没做 —— 预算花在调用点可读性上,没花在路径存在性上。

这是典型的取舍失误:难的检查做了,便宜的检查没做。

横向对照:DSH 把「每个包必须拥有 ./invariant」同时写成散文和 21 行 verify-package-invariants(失败 process.exit(1)),空安装器必须带固定前缀 No runtime invariant: —— 空是显式架构结论,不是疏忽。


思路六 · 两种安全视角

视角问什么依据
可回放出事之后,现场还能不能拼回来JSONL 原文
可拒绝出事之前,有没有一道门能说不四道门 + 出站漏斗

可回放的边界

  • JSONL 是原文,只追加已定稿条目,不从内容推断元数据;SQLite 是镜像,给列表检索用。元数据丢了可再抽,JSONL 丢了恢复必须读文件。出处:thread-store/README.md L22–28。
  • 持久化策略会丢掉流式增量、审批弹窗、警告、MCP 启动进度;TurnStarted 留下。能回放 turn 边界和完成态,回放不了当时屏幕上闪过的审批文案。出处:rollout/src/policy.rs L86–105。
  • fork 认物理边界:文件里真有一条 TurnStarted,进行中的 turn 直接拒绝,投影出的合成 ID 不能当切点。出处:thread_rollout_truncation.rs L187–191。

可拒绝的四道门

  1. 策略:Decision 只有 Allow / Prompt / Forbidden,用序取最严;规则文件的 not_match 加载器真跑一遍,反例命中则会话开不起来。出处:execpolicy/src/decision.rs L9–16、rule.rs L281–306。
  2. 审批:精确 key = 规范化后的完整 argv + cwd + 权限。npm run test 与 npm run lint 是两把 key。出处:unified_exec.rs L86–97。
  3. Guardian:超时或坏输出就关闸,只认明确的 allow / deny;挡审批疲劳,但用户本人点批准时让路。出处:guardian/mod.rs L1–12。
  4. OS 沙箱:macOS 拼 SBPL;Linux 默认 bubblewrap + seccomp,失败不回退遗留 Landlock;Windows 受限令牌,开关关着返回 None。出处:sandboxing/src/manager.rs L36–42。
  5. 出站漏斗:403 + x-proxy-error 头回给命令进程,循环继续。出处:network-proxy/src/responses.rs L76–83。

反直觉 · 不要把工具失败升级成引擎错误

沙箱拒绝走的是成功的工具输出:process_id 被清掉,exit_code 留在正文里,记日志时成功位恒为 true。

若把沙箱拒绝升级成引擎错误,模型看不到退出码,只会换一条更绕的命令。工具层只允许两种失败:回喂模型,或打断引擎。出处:function_call_error.rs L1–10;core/src/tools/context.rs L340–353。


审查清单

  • 流式渲染是否显式区分了「已固化」与「可变」两区?
  • 未换行的 source 是否被缓冲而不是直接改可见尾巴?
  • 表格是否等到闭合才进不可变区?sh 围栏里的竖线有没有误触发?
  • 队列排水是单档还是两档?阈值是否同时看深度和年龄?
  • 一帧内的多处写入是否包在同一次原子提交里?
  • 架构规则里,哪些有红灯、哪些只有散文?散文那部分有没有便宜的自动检查?
  • 路径存在性有没有脚本核对?(二十行即可)
  • 特性开关是否有穷尽表锁住登记与默认值?
  • 四道门(策略 / 审批 / Guardian / OS 沙箱)是否各就各位?
  • 工具失败是否默认回喂模型,而非打断引擎?

排查路径

  1. 界面卡住 → 先看这一帧有没有换行(没换行是设计内行为)。
  2. 表格残影 → 看表格是否在闭合前就被提交进了不可变区。
  3. 合并前不红但功能异常 → 检查该规则是不是只写在 Markdown 里。
  4. 能完整回放却没拦住 → 说明只有可回放侧、可拒绝侧有缺口,逐道门排查。
  5. 模型换更绕的命令重试 → 检查是否把工具失败误升级成了引擎错误。

约束说明

  1. 取材约束:本集全部内容取自小山学堂《学 AI 产品,从入门到精通》对应课节,未跨集取材,未虚构源码行号、文件名或案例。
  2. 取舍说明:三节合计 12144 字,口播稿按 4600–5300 字容量做取舍,保留最能带走的原理与踩坑点,未为凑字数硬扩。
  3. 题库隔离:题库页(quizFiles)一律不进入口播稿,仅作为集页下方的文字自测卡渲染。
  4. 源码时效:文中行号对应 openai/codex 仓库 commit 4f39251a01;横向对比部分核对时间为 2026-08-22。
  5. 解读边界:本文为二次演绎的解读稿,用于配合音频理解;具体行为以实际运行版本为准。

课堂练习

  1. 模型在一个超长段落中间一直不吐换行。对照「未结束的 source 进缓冲,不能改可见尾巴」,写出:可见尾巴何时更新,这段文字何时进入 scrollback。
  2. 进阶:如果这段文字其实是表格的半截行,扣留打开和关掉时,用户分别会看见什么?
  3. AGENTS.md 第 35 行和第 265 行都是失效路径。若只能先做一道自动检查,你检查带 codex-rs/ 前缀的路径,还是检查所有反引号里含 / 的字符串?写出过滤规则。
  4. 推演:一条带令牌的出站请求,为什么四道门过完仍然放行,而可回放侧有 JSONL 也拼不回环境变量?

Takeaway

没换行的 token 不可见。能确定不再变的行进终端 scrollback,提交即固化;还可能变的行,尤其是没闭合的表,只活在活动尾巴里,等流结束再一次定稿。

能局部检查的决策,不要只写在 Markdown。条款告诉人审什么,红灯在人没看的时候仍然亮。路径存在性和穷尽表,比养一台 rustc 插件更便宜,也更先该做。

可回放问出事之后能不能复原,可拒绝问出事之前有没有门。先问三个问题再决定抄哪一侧:跑在谁的机器上,处理谁的数据,失败的代价是密钥泄漏、仓库被改,还是评测不可复现。


*来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)*