第 5 章:流式输出是一串有边界的事件
当屏幕上的文字逐步出现时,很容易把一条网络读取结果当作一个 token 或一条消息。实际上,TCP 提供字节流,HTTP 客户端的读取边界也不是 SSE 事件边界。一段中文可能被拆在两个读取结果里,两个事件也可能一次到达。
5.1 从字节到事件需要两层缓冲
需要保留跨网络分片的 UTF-8 字节,再按 SSE 的行和空行规则组装事件。可以采用增量解码器,也可以像实验一样,先在有界字节缓冲中找到完整行,再解码该行。不能对每个网络分片单独调用无状态的字符串解码,然后用替换字符掩盖错误。那样可能在网络健康时就损坏内容。
WHATWG 的 SSE 格式允许多个 data 行构成一个事件;注释行、事件类型与事件标识也有独立含义。一个只寻找 data: 并截取当前分片的解析器,通常经不起分片、换行和多行数据测试。
本书实验通过 POST 发起生成,再由自己的客户端解析响应流。浏览器原生 EventSource 的建连方式和请求限制与这个接口不同。使用 SSE 编码,并不自动获得 EventSource 的重连和 Last-Event-ID 语义;接入浏览器时要选择与认证、请求体和恢复合同匹配的客户端。
5.2 完整事件才进入下一步
实验按 token、usage、done 的逻辑组织流。这里的 token 事件只是增量文本事件的名称,不代表真实 tokenizer 的一个 token。客户端要累积事件,并检查最后是否存在符合合同的结束标志。
假设上游发送以下逻辑序列:
event: token
data: {"text":"你好"}
event: usage
data: {"contract":"utf8-bytes-v1", "input_units":3, "output_units":6}
event: done
data: {}
这是帮助理解事件角色的示意。usage 中的输入值取决于实际 prompt 的 UTF-8 字节数,不能把这组数直接用来替代运行记录。网关要先验证输入量、已转发输出量和计量合同,再决定是否接受结算。
5.3 提交以后,错误的表达方式改变
网关一旦把事件发给客户端,就已经改变了用户看到的状态。上游在此之后中断,客户端应保留“已显示部分结果但未完成”的事实。用备用模型从头生成再拼到后面,会让用户看到重复或矛盾内容。
如果业务允许用户明确点击“重新生成”,新请求可以作为新的操作发起。界面应标明先前结果不完整,审计应保留两次调用。把重新生成作为显式业务行为,比在传输层偷偷拼接更容易解释和计费。
5.4 慢客户端会把内存变成队列
上游每秒产生大量内容,而下游缓慢读取。如果中间使用无界队列,网关内存会随持续时间增长。有界队列让生产者在缓冲满时等待,进而减少上游读取;连接和操作系统缓冲也有容量,这些层次共同决定资源占用。
有界队列只证明本地该队列不会无限增长,不能证明供应商已经停止生成或计费。生产系统还需设定最大事件尺寸、总输出量、写入超时和慢连接处理策略。按“事件个数”限制队列时,单个巨大事件仍可能占据大量内存,应另加字节上限。
练习。 将一个包含中文的 SSE 事件在每一个可能的字节位置切成两段。解析后应该得到什么?如果测试只使用英文,它可能漏掉哪种错误?