1. 为什么流式打字机效果值得单独写一个 Skill如果你用 Codex 做过对话类工具大概率遇到过这个场景模型明明已经在返回内容了但界面上什么都没有直到整段回答生成完毕才“啪”地一下全部出现。用户盯着空白区域等十几秒会怀疑程序卡死甚至直接关掉窗口。这不是模型慢而是客户端把流式数据攒成了完整响应再渲染。ChatGPT 那种一个字一个字往外蹦的效果本质上是把 SSEServer-Sent Events返回的增量 token 立即渲染到界面。终端里要处理 ANSI 光标控制前端要处理 ReadableStream 和 DOM 追加两边逻辑完全不同但都可以封装成 Codex Skill 复用。我试过把这套逻辑拆成独立的 Skill 模块后终端工具和 Web 前端共用同一套解析层只替换渲染层维护成本直接砍半。这篇会从 Skill 结构设计讲到双端验证给出可复制的配置片段和排障清单。适合已经在用 Codex 写对话工具、但输出体验还停留在“全量等待”阶段的开发者。读完你能拿到一个能直接跑的 Skill 骨架以及通过 TaoToken 统一模型调用通道的接入方式让终端和浏览器里的输出节奏保持一致。核心检索词先明确Codex Skills 流式打字机输出指的是在 Codex 的 Skill 体系下编写一个负责 SSE 流解析与逐字渲染的模块让终端和前端都能实现类似 ChatGPT 的 Typing 效果。它解决的是感知延迟问题不是模型推理速度问题。2. 前置准备用 TaoToken 统一 Key 与 API 通道在写 Skill 之前先把模型调用通道固定下来。终端和前端如果各自维护一套 Key 和 Base URL联调时会出现“终端能跑、浏览器 401”这种低级问题。我的做法是让两端都走同一个 API 入口Key 从环境变量读取Skill 内部只认base_url和api_key两个参数。TaoToken 在这里的角色是统一通道你可以在官网拿到 Key然后在 Skill 里把 Base URL 指向https://taotoken.net/api。这样终端 Python 脚本和前端 fetch 请求用的是同一套鉴权信息排查问题时只需要确认一处配置。具体操作路径先访问官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册并创建 API Key然后在控制台确认你的可用模型列表。Key 生成后不要硬编码进 Skill 源码用.env或系统环境变量注入。终端侧建议这样组织配置放在项目根目录的.env里TAOTOKEN_API_KEYsk-你的实际key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODELgpt-4o-mini前端侧不要直接把 Key 写进浏览器代码而是让前端请求你自己的后端由后端持有 Key 并转发到 TaoToken。如果你只是本地演示可以在后端加一个/chat接口做代理。这样既避免 Key 泄露也方便统一加限流和日志。Skill 的目录结构建议这样设计让解析层和渲染层分离skills/ stream_typewriter/ __init__.py sse_parser.py # SSE 解析两端逻辑一致 terminal_render.py # ANSI 渲染 web_render.js # 前端渲染参考实现 config.toml # Skill 元信息与默认参数config.toml里声明 Skill 的入口和默认模型参数Codex 加载时能识别[skill] name stream_typewriter version 0.1.0 entry sse_parser:StreamTypewriterSkill description SSE 流式解析与打字机渲染 [defaults] base_url https://taotoken.net/api model gpt-4o-mini temperature 0.7 render_interval_ms 16这里render_interval_ms是关键参数控制渲染节奏。设成 16 大约对应 60fps视觉上最接近 ChatGPT设成 0 会每个 token 都重绘终端可能闪烁设成 50 以上会有明显卡顿感。后面验证环节会具体调这个值。3. 可复制配置Skill 核心代码与双端接入片段这一节给出能直接复制运行的代码。先看 SSE 解析层它负责把网络字节流切成一个个 token不关心渲染方式。# skills/stream_typewriter/sse_parser.py import json import httpx from typing import Generator, Optional class StreamTypewriterSkill: def __init__(self, api_key: str, base_url: str https://taotoken.net/api, model: str gpt-4o-mini): self.api_key api_key self.base_url base_url.rstrip(/) self.model model self.client httpx.Client( base_urlself.base_url, headers{ Authorization: fBearer {self.api_key}, Content-Type: application/json, }, timeout60.0, ) def _parse_sse_line(self, raw: str) - Optional[str]: 剥离 data: 前缀返回 JSON 字符串或 [DONE] if raw.startswith(data: ): return raw[6:] if raw.strip() data: [DONE]: return [DONE] return None def _extract_delta(self, json_str: str) - str: 从 choices[0].delta.content 提取增量文本 try: data json.loads(json_str) choices data.get(choices) or [] if not choices: return delta choices[0].get(delta) or {} return delta.get(content) or except json.JSONDecodeError: return def stream_tokens(self, messages: list, temperature: float 0.7 ) - Generator[str, None, None]: 逐 token 产出文本供渲染层消费 payload { model: self.model, messages: messages, stream: True, temperature: temperature, } with self.client.stream(POST, /v1/chat/completions, jsonpayload) as resp: resp.raise_for_status() for line in resp.iter_lines(): if not line: continue cleaned self._parse_sse_line(line.decode(utf-8)) if cleaned is None: continue if cleaned [DONE]: break token self._extract_delta(cleaned) if token: yield token注意base_url指向https://taotoken.net/api时请求路径要写成/v1/chat/completions因为 TaoToken 的 API 入口已经包含了/api前缀。如果你把 base_url 写成https://taotoken.net/api/v1那路径就改成/chat/completions。两种写法都行但 Skill 内部要统一否则终端和前端容易对不上。终端渲染层用 ANSI 转义序列做原地刷新# skills/stream_typewriter/terminal_render.py import sys import time class TerminalRenderer: def __init__(self, interval_ms: int 16): self.interval interval_ms / 1000.0 self.buffer def render(self, token: str): self.buffer token # \033[0G 回到行首\033[2K 清除整行 sys.stdout.write(f\033[0G\033[2K{self.buffer}) sys.stdout.flush() time.sleep(self.interval) def finish(self): sys.stdout.write(\n) sys.stdout.flush()前端侧用 fetch ReadableStream核心是缓冲区处理跨帧的 SSE 消息// web_render.js 关键片段 async function streamChat(messages, onToken) { const resp await fetch(/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, stream: true }) }); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); // 保留不完整行 for (const line of lines) { if (!line.startsWith(data: )) continue; const data line.slice(6); if (data [DONE]) continue; try { const json JSON.parse(data); const token json.choices?.[0]?.delta?.content || ; if (token) onToken(token); } catch (e) { /* 忽略解析失败的行 */ } } } }前端渲染时不要用innerHTML 那样会重置光标动画。正确做法是在光标span前插入文本节点const cursor document.createElement(span); cursor.className cursor; aiDiv.appendChild(cursor); function onToken(token) { aiDiv.insertBefore(document.createTextNode(token), cursor); chatMessages.scrollTop chatMessages.scrollHeight; }CSS 里给光标加闪烁动画.cursor { display: inline-block; width: 2px; height: 1em; background: #333; animation: blink 1s step-end infinite; vertical-align: middle; } keyframes blink { 50% { opacity: 0; } }到这里终端和前端共用同一个stream_tokens解析逻辑只是渲染层不同。Codex 加载 Skill 后你可以在终端直接调用也可以让前端后端代理调用。4. 验证请求终端与浏览器双端跑通先验证终端侧。写一个最小调用脚本# demo_terminal.py import os from dotenv import load_dotenv from skills.stream_typewriter.sse_parser import StreamTypewriterSkill from skills.stream_typewriter.terminal_render import TerminalRenderer load_dotenv() skill StreamTypewriterSkill( api_keyos.environ[TAOTOKEN_API_KEY], base_urlos.environ[TAOTOKEN_BASE_URL], modelos.environ.get(TAOTOKEN_MODEL, gpt-4o-mini), ) renderer TerminalRenderer(interval_ms16) messages [{role: user, content: 用三句话解释什么是流式响应}] for token in skill.stream_tokens(messages): renderer.render(token) renderer.finish()运行python demo_terminal.py你应该看到文字逐字出现光标停在行尾最后换行。如果文字是整段蹦出来的说明streamTrue没生效或者你用的库把响应体缓存了。如果屏幕闪烁严重把interval_ms调到 30 左右。前端验证需要一个后端代理。用 FastAPI 写一个最小转发# server.py from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel import os, httpx app FastAPI() class ChatReq(BaseModel): messages: list stream: bool True app.post(/chat) async def chat(req: ChatReq): async def gen(): async with httpx.AsyncClient(timeout60.0) as client: async with client.stream( POST, f{os.environ[TAOTOKEN_BASE_URL]}/v1/chat/completions, headers{Authorization: fBearer {os.environ[TAOTOKEN_API_KEY]}}, json{model: gpt-4o-mini, messages: req.messages, stream: True}, ) as resp: async for chunk in resp.aiter_bytes(): yield chunk return StreamingResponse(gen(), media_typetext/event-stream)启动uvicorn server:app --port 8000然后打开前面的 HTML 页面输入问题。浏览器 Network 面板里应该能看到/chat请求处于 pending 状态Response 逐步增长。页面上文字逐字出现光标持续闪烁滚动条自动跟随。两端都跑通后对比一下节奏。终端因为time.sleep是阻塞的实际节奏受 token 到达速度影响前端是事件驱动token 到了就渲染。如果你希望两端完全一致可以在前端也加一个最小间隔节流用requestAnimationFrame控制重绘频率。5. 常见报错排查401、local proxy failed 与 choices 解析失败排障时先确认三件事Base URL、Key、Model ID 是否三件套齐全。缺任何一个都会报错而且报错信息往往不直观。401 Unauthorized最常见。先检查.env里的 Key 有没有多余空格再确认Authorization头是不是Bearer sk-xxx格式。如果你用的是 TaoTokenKey 从控制台复制时不要带换行。终端报 401 但前端正常通常是终端读的环境变量没加载用python -c import os; print(os.environ.get(TAOTOKEN_API_KEY))确认一下。local proxy failed / connection refused这个报错说明请求根本没发出去或者被本地网络层拦截了。检查base_url是不是写成了https://taotoken.net/api/带尾斜杠有些 HTTP 客户端拼接路径时会产生//v1导致 404。另外确认你的运行环境能正常访问外网 HTTPS公司内网可能需要配置系统级证书。reading choices 报错 / KeyError: choices说明 SSE 解析时拿到的 JSON 结构不对。可能原因有三个一是请求路径写错返回的是错误页 HTML 而不是 JSON二是模型名写错服务端返回了错误对象三是流式和非流式混用非流式响应里choices[0]有message而不是delta。在_extract_delta里加一行print(data)调试看实际返回结构。OAuth / auth.json 相关报错如果你在 Codex 里配置了多个模型通道可能会遇到鉴权文件冲突。Codex 的auth.json里存的是通道凭证如果你同时配了官方通道和 TaoToken 通道要确认当前 Skill 用的是哪一个。建议在 Skill 的config.toml里显式写死base_url不要依赖全局默认值。终端文字重叠或错位ANSI 转义序列在部分终端模拟器里支持不完整。Windows Terminal 和 iTerm2 没问题老版本 cmd 可能不支持\033[2K。如果遇到降级方案是每次print新 token 而不是重绘整行代价是换行时会有跳动。前端光标不闪烁检查是不是用了innerHTML更新内容。只要动了innerHTML光标元素就被重建动画从头开始看起来像卡住。坚持用insertBefore插入文本节点。中文换行位置错误终端里中文字符占两个英文字符宽度按字符数切分会错位。引入wcwidth库计算显示宽度或者简单点把终端宽度按 0.5 折算给中文。6. 继续深入把 Skill 用到真实项目里跑通 demo 之后你可以把这套 Skill 接到实际项目。终端侧适合做 CLI 对话工具、日志分析助手前端侧适合做客服面板、代码补全界面。两端的解析层完全复用只有渲染层需要按场景调整。如果你要长期跑编码类 Agent建议把模型调用切到 Coding Plan 通道这样在 Codex 里做多轮工具调用时额度更稳。验证模型输出效果时可以直接在模型对话页面测试不同 prompt 的流式表现确认 token 粒度和节奏符合预期。接入文档里有完整的参数说明和错误码对照排障时比猜要快得多。最后留一个实用技巧在 Skill 里加一个--dry-run模式把 token 打印成带时间戳的日志而不是渲染到屏幕。这样调试节奏问题时你能看到每个 token 的实际到达间隔判断是网络抖动还是渲染层阻塞。这个模式在联调前后端时特别有用能快速定位是模型侧慢还是客户端慢。