Provider 抽象与可取消的流式对话
先看一次失败的请求
最初的 /chat 通常这样写:
当模型需要 12 秒时,浏览器 12 秒内什么都看不到;反向代理还可能在 60 秒后直接断开。把 stream=True 加上也不够:你仍然需要定义事件格式、会话 ID、取消路径和断线重连行为。
Provider 是稳定边界
MockProvider 不是玩具
Mock 必须“决定性”,不能随机生成文字:
测试可以断言每一帧,不需要等待真实模型,也不会因为供应商改了措辞而抖动。
SSE 事件协议
首帧必须包含 conversation_id,否则客户端无法调用 /cancel 或 /approve:
把这套协议按时间画开,取消为什么必须依赖首帧就很清楚了:
服务端实现:
/cancel 是另一条 HTTP 连接,不能依赖请求上下文里的 asyncio.Event。单进程可以用内存字典,多进程必须把取消标记放 Redis,并设置过期时间避免泄漏。
上下文裁剪不是简单切片
把最近 20 条消息全部塞给模型,会让历史挤掉当前问题。用 token 预算明确分配:
真实项目中用 tiktoken 估算,供应商不支持时按字符数保守兜底,并在日志里记录裁剪前后的 token 数。
浏览器调试页和断线处理
最小客户端不要先上 React。下面这一页 HTML 直接存成 debug.html 用浏览器打开就能用,它是本篇最值回票价的东西——你能看见 token 一个个冒出来,而不是盯着 curl 猜协议对不对:
有三个边界细节会原样保留到第 14 篇的 React 实现中:
buffer = frames.pop()把最后一段残帧留到下一次读取。TCP 不保证一次read()正好落在帧边界上,少了这行就会随机丢字。frame.startsWith(':')跳过心跳。不跳过的话,JSON.parse会在: ping上抛异常,整个流就断了。- 取消按钮用的是
status帧里存下来的conversationId。这就是首帧必须先发的原因——按钮在第一个 token 出现之前就得可用。
断线重连时携带 Last-Event-ID 或自定义 last_seq,服务端从 Redis/数据库重放缺失事件;第 14 篇会把这段逻辑移入 React 状态机。
测试、故障排查与验收
常见故障:
- 浏览器只收到最后一帧:检查响应头
Content-Type: text/event-stream、Cache-Control: no-cache,并关闭代理缓冲。 - 中文乱码:每个 chunk 用
TextDecoderStream增量解码,不要对单帧调用decode()。 include_usage报 400:兼容服务不支持时捕获错误,关闭该字段并用 tokenizer 估算。- 取消无效:确认取消连接和流连接使用同一个
conversation_id,跨进程时确认 Redis 通道一致。
本篇验收标准
- Mock 模式不配置 API Key 也能输出 token、done 事件。
- 首帧在 1 秒内返回
conversation_id,每个 token 有递增seq。 - 调用
/cancel后最多再收到一个 token,最终收到cancelled而不是done。 - 历史超过预算时保留 system、当前问题和完整消息对,不产生孤立的 tool 消息。
下一篇会让模型真正“做事”:工具调用循环只接入只读工具,先解决 schema、伪造结果和无限循环。
