用户问"公司报销流程怎么走",模型可能十几秒后才交出完整段落。命令行像卡住了一样,用户又按一次回车,后台便多出一次请求。今天用 Python 做一个最小答疑程序:文字到达就显示,等太久会结束,按 Ctrl+C 可以取消。学到的是网络流的消费方法,换模型、换前端仍然用得上。
为什么不是把普通请求的结果切成小段
普通请求的服务器响应在生成结束后才返回。流式请求让服务器使用 SSE(Server-Sent Events,服务器发送事件)逐个传递事件。response.output_text.delta 携带新文本,response.completed 表示正常结束,response.failed 指向服务端失败。收到事件不等于答案已经可靠完成:如果网络断开,屏幕上可能只有半句话。因此示例明确区分"显示过内容"和"已完成"。
这篇文章以近期 Python SDK 对 Responses 流快照处理的更新为引子,刻意使用标准库直接读 REST(表述性状态传输)接口。这样可看清最底层的事件边界;应用生产环境时,官方 SDK 通常能减少协议细节工作。模型用 gpt-5,因为官方快速入门有该模型的 Responses 调用示例;实际账户可用性仍需在运行前确认。场景只给一段公共示例政策,不能把真实内部政策硬编码到程序里。
环境准备与完整代码
需要 Python 3.10 或以上;代码只用标准库,无第三方依赖。先在自己的终端设置 OPENAI_API_KEY。macOS/Linux 可运行 export OPENAI_API_KEY='你的密钥';Windows PowerShell 使用 $env:OPENAI_API_KEY='你的密钥'。这两个命令是本机操作说明,不要把真实密钥提交到仓库。将下段保存为 stream_qa.py,运行 python3 stream_qa.py。示例默认网络连接超时 10 秒,整体读取超时 45 秒。
python
import json
import os
import socket
import sys
import urllib.error
import urllib.request
URL = "https://api.openai.com/v1/responses"
MODEL = "gpt-5"
def ask(question: str) -> None:
key = os.environ.get("OPENAI_API_KEY")
if not key:
raise RuntimeError("请先设置 OPENAI_API_KEY")
if not question.strip():
raise ValueError("问题不能为空")
payload = {
"model": MODEL,
"stream": True,
"input": [
{"role": "developer", "content":
"只根据这段示例政策回答:报销须提交发票,并由直属主管审批。"
"没有依据时明确说不知道。"},
{"role": "user", "content": question},
],
}
request = urllib.request.Request(
URL,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Authorization": f"Bearer {key}",
"Content-Type": "application/json"},
method="POST",
)
completed = False
shown = False
try:
with urllib.request.urlopen(request, timeout=45) as response:
for raw in response:
line = raw.decode("utf-8", errors="replace").strip()
if not line.startswith("data: "):
continue
if line == "data: [DONE]":
break
event = json.loads(line[6:])
kind = event.get("type")
if kind == "response.output_text.delta":
print(event.get("delta", ""), end="", flush=True)
shown = True
elif kind == "response.completed":
completed = True
elif kind in ("response.failed", "error"):
raise RuntimeError(f"服务端事件失败:{kind}")
except urllib.error.HTTPError as exc:
detail = exc.read(400).decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
raise RuntimeError(f"网络或读取超时:{exc}") from exc
finally:
if shown:
print()
if not completed:
raise RuntimeError("连接结束但没有完成事件;上方内容可能不完整")
if __name__ == "__main__":
try:
ask(input("请输入报销问题:"))
except KeyboardInterrupt:
print("\n已取消;请勿将已显示的片段当成完整答案", file=sys.stderr)
sys.exit(130)
except (RuntimeError, ValueError) as exc:
print(f"失败:{exc}", file=sys.stderr)
sys.exit(1)
逐段看关键逻辑
请求体里 stream: true 决定传输形态;用户问题与示例政策分为两个角色,便于后来把政策替换为检索结果。for raw in response 按行读取,空行和 event: 行被跳过,只有 data: 行进入 JSON(JavaScript Object Notation,结构化数据格式)解析。这是一个适合入门的 SSE 消费器;如果代理把超长事件拆成多行 data:,需要改成按空行聚合一整个事件。
每个 delta 都直接打印,flush=True 使终端立即刷新。completed 标志是完整性门槛:看见了字却没有结束事件,程序仍报错。按 Ctrl+C 时关闭上下文中的连接,并明确提醒用户片段不完整。这里的 timeout=45 是套接字等待超时,不是严谨的整个请求总时限;生产环境还要加应用层截止时间,以及请求标识和取消日志。
成功时会先出现逐字或逐块增长的答复,例如"按照示例政策,提交发票并请直属主管审批"。输出内容由模型生成,不保证逐字相同。没有密钥时应立即报"请先设置";若模型不可用,可能收到 404 或权限类错误。示例未在本次任务中实际调用线上API。
常见错误与边界
第一,401 常是密钥未设置或失效;先确认环境变量存在,别把密钥打印到日志。第二,429 表示限流或额度问题;减少并发或按响应头退避,不能无上限重试。第三,终端显示半句后结束:检查是否收到完成事件,网络错误时应在界面标记"未完成"。第四,如果回答引用了示例之外的政策,说明输入约束不足,业务系统还需检索证据和人工校验。
适用场景是长回答、互动问答和需要尽早展示进度的前端。不适用的是必须一次性交付完整且可验证 JSON 的机器间事务;这种场景应先拿到完整结果再校验。工程化时可将事件消费层与展示层分开,记录首字延迟、完成率、取消率,并对同一问题设置请求去重键。缓存也要区分"完整答案"和"中途片段",避免断线内容污染知识库。
把例子接到网页时,服务器应继续作为唯一持有密钥的一端,浏览器只连接自家后端。后端还要给每位用户设置并发上限。流式输出会占住连接,用户反复刷新页面可能耗掉连接池;这和模型推理费用是两种资源。先测并发请求的首字时间与完整结束率,再决定规模。
"停止显示"和"停止计费"也并非同一件事。按下取消后本地连接关闭,服务端是否已处理部分请求,取决于取消传播时机。日志最好保留自己的请求 ID、开始与取消时间、最后一个事件类型,便于排查重复请求;用户问题只在确有业务需要时留存。若用流式结果驱动工具,切勿把未闭合的 JSON 片段直接交给执行器。展示文本可以不完整,数据库写入却必须等参数完整、解析成功并通过权限校验。
5 分钟实践:把示例政策改为"报销须在 30 天内提交",问一个文档没有回答的问题,观察模型能否说不知道;然后按 Ctrl+C,确认程序不会输出"完成"标记。你的答疑产品更需要首字速度,还是完整答案的可靠性?
还有一处产品细节:用户看到文字逐步出现,容易以为模型已经"确定"了答案。实际上,流事件表达的是生成过程,不是审核过程。界面可以在进行中显示"草稿生成中",收到完整事件后再标记"回答结束";若后续还要做引用核对,应另设"证据已核验"状态。三个状态分开,能减少用户把半成品截图当正式结论的情况。对客服业务尤其如此,涉及报销期限、审批人的句子最好在后端查到现行政策版本后再展示。
处理中文时,终端输出看似按字出现,但事件并不保证一次一个汉字。网络缓冲、代理和模型生成节奏都会改变块大小,因此不要以块数估算 Token 或费用。也不要在每个小块后立即写数据库:高频写入会拖慢请求,还可能造成大量碎片记录。一个常见办法是把片段暂存在内存,每隔数百毫秒刷新一次界面,只在完整事件之后保存正式答案;断线片段可以单独存为排障资料并设置短保留期。
若公司网络通过代理访问 API,要把代理关闭连接、重试和超时行为纳入测试。连接在第 44 秒静默断开,与服务器返回明确的失败事件,在用户界面上都应显示"本次答案不完整",但排查路径不同。第一种应检查网络和代理日志,第二种看接口错误字段。把这两类失败混成"AI不会回答",会使团队不断改提示词,却解决不了真正的连接问题。
关注「蜗牛聊AI」,一起看懂技术变化背后的真正机会。
本文首发于 java4u.cn,转载请注明出处。