登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

AI 流式响应中的 finish_reason 如何决定持久化时机

来源:17golang原创

时间:2026-09-15 10:31:03 195浏览 收藏

流式输出不要在“最后一个文字片段到达”时直接写入正式表。更稳妥的做法是先把增量内容放入请求级缓冲区,等结束信号明确后,再根据 finish_reason 把记录标为已完成、部分完成或等待工具。这样,断线、长度截断和工具调用都不会被误记成正常答案。

官方地址:https://platform.openai.com/docs/

持久化时机由“内容已收集”与“结果已结束”共同决定:只有内容完整且结束原因允许提交时,才把草稿提升为正式记录;length、工具调用或过滤中断应保留状态,不能只看缓冲区是否有文字。
要点速览
  • 增量文本和结束原因分开存储,不能用空字符串或最后一块 delta 代替结束信号。
  • stop 通常可以进入 completed;length 应进入 partial;工具调用要等工具结果回流。
  • 用 request_id、幂等键和状态机保护重试,避免重复结束事件生成多条正式记录。

先累计增量,再等待结束信号

流式协议通常把一次回答拆成多次事件。每次事件可能只有一小段文本,最后一个事件才携带结束信息。因此服务端至少要维护三个字段:按请求隔离的文本缓冲区、当前 finish_reason,以及持久化状态。

AI 流式响应增量进入缓冲区并等待 finish_reason 的服务端操作示意图
图1:流式增量进入缓冲区的操作示意图,结束原因尚未确认前不提交正式记录。

下面的示例只演示决策位置。真正接收 SDK 事件时,应以所用 SDK 的字段结构为准;不要把示例中的输出当作本机运行截图。

// 将增量文字与结束原因分开保存,避免最后一段文字被误判为完整结果
const state = { text: [], finishReason: null, status: "streaming" };

for await (const chunk of stream) {
  const delta = chunk.choices?.[0]?.delta?.content;
  const reason = chunk.choices?.[0]?.finish_reason;

  // 文字片段只进入缓冲区,不在这里写正式记录
  if (delta) state.text.push(delta);
  if (reason) state.finishReason = reason;
}

// 只有流结束且拿到结束原因,才进入统一的持久化决策
const text = state.text.join("");
if (!state.finishReason) state.status = "incomplete";

如果传输层先断开,缓冲区里可能已经有看似完整的句子,但这只能说明“收到了部分内容”。应该把它保存为可恢复草稿,记录断线原因和最后收到的序号,而不是覆盖上一条正式答案。

按 finish_reason 决定提交或保留草稿

结束原因是持久化策略的分叉点。常见 Chat Completions 形态可以按下面的表处理;不同模型或兼容服务的具体枚举仍要以其接口文档为准。

结束原因推荐状态处理动作
stopcompleted保存完整文本,记录结束时间和用量
lengthpartial保留当前文本,提示截断,不冒充完整答案
tool_callsawaiting_tool先保存工具调用参数,工具返回后继续流程
content_filter 或未知值review保留原始状态,交给策略层决定是否展示或重试
finish_reason stop length tool_calls 映射为 completed partial awaiting_tool 的结果示意图
图2:不同 finish_reason 映射到持久化状态的结果示意图。

这里最容易犯的错是把所有非空文本都标为 completed。比如 length 代表生成达到长度上限,文本可能在 Markdown 表格或 JSON 中间被截断;它可以对用户展示为“未完成草稿”,却不应进入需要完整结构的下游任务。

用幂等键和状态字段防止重复写入

网络重试可能让消费者再次收到结束事件。建议以请求 ID 加业务幂等键定位一条生成记录,并让状态只沿允许的方向变化:streaming → completed,或 streaming → partialstreaming → awaiting_tool。已经 completed 的记录再次收到相同结束事件时只返回成功,不再插入新行。

// 状态转换集中在一个函数里,调用方只提交事件,不直接改数据库状态
function decidePersistence(reason, text) {
  // 空内容可能来自异常结束,先保留为 incomplete 便于恢复
  if (!reason) return { status: "incomplete", publishable: false };
  if (reason === "stop") return { status: "completed", publishable: text.length > 0 };
  if (reason === "length") return { status: "partial", publishable: false };
  if (reason === "tool_calls") return { status: "awaiting_tool", publishable: false };
  return { status: "review", publishable: false };
}

// upsert 使用 request_id + idempotency_key,重试不会制造第二条正式记录
const decision = decidePersistence(state.finishReason, text);
await saveGeneration({ requestId, idempotencyKey, text, ...decision });

数据库层仍应有唯一约束,应用层判断不能代替约束。若工具调用需要多轮继续,保存工具名、参数和当前回答片段;工具返回后新一轮输出使用新的事件序号,但沿用同一个业务生成记录。

协议边界与结果验证

不要把不同 API 的结束模型混为一谈。Chat Completions 常见的是 choice 上的 finish_reason;Responses API 的流式文档则使用 response.completedresponse.incomplete 等事件表示最终状态。若项目迁移了 API,先改事件适配层,再复用下面的持久化状态机。

写入前可做三项轻量检查:请求 ID 是否属于当前用户会话;文本缓冲是否按事件序号去重;结束原因是否与状态映射一致。检查失败时保留原始事件和 partial 内容,方便恢复,不要为了“有一条结果”而强行标记成功。

常见问题

收到最后一段 delta 就能保存吗?

只能保存为草稿。应等协议给出结束事件或结束原因,并确认没有传输错误后,再决定是否提升为正式记录。

length 的文本要不要直接丢弃?

不必丢弃。保留为 partial,并把截断原因展示给上层;对要求完整 JSON、代码或表格的场景,不要把它送入后续自动处理。

Responses API 还能直接读取 finish_reason 吗?

不能假设字段完全相同。应按 Responses API 的事件类型适配完成和不完整结果,再把它们映射到统一的 completed、partial 或 review 状态。

为什么还需要幂等键?

因为断线重连、队列重投和消费者重启都可能重复处理同一结束事件。唯一约束和幂等 upsert 能把重复处理变成安全重试。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>