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

AI 流式输出总是半截 JSON:用 Go 增量缓冲器拼出完整工具参数

来源:17golang原创

时间:2026-07-26 15:02:20 497浏览 收藏

AI 接口改成流式返回后,最容易踩到的坑不是网络断开,而是把每个数据块误当成一个完整 JSON。工具参数可能被拆成 {"city":"上海"} 三段,逐块调用 json.Unmarshal 就会得到意外 EOF,重试逻辑还可能把同一次工具调用提交两遍。

流式块只代表传输片段,不代表语义边界。先按请求拼接并确认 JSON 完整,再把参数交给工具层,是最稳妥的处理顺序。

要点速览

  • 每个流式块都可能从字符串中间切开,不能按块直接解析。
  • 缓冲器需要同时记录请求标识、工具调用标识和原始片段。
  • 只有完整 JSON 通过语法解析和字段校验后,才允许提交工具参数。
  • 提交成功后要记住 tool_call_id,重连或重复片段不能再次触发。

问题现场:工具参数在半路变成意外 EOF

假设应用让模型选择天气工具,参数结构很小:

{"city":"上海","unit":"celsius"}

非流式响应通常一次拿到完整字符串;流式响应却可能按网络缓冲、服务端调度或上游事件格式拆分。下面这组片段完全合法,却没有一段单独构成完整对象:

{"city":"上
海","unit":"celsius"}

如果接收端在第一段就调用 json.Unmarshal,错误信息大概率是 unexpected end of JSON input。这个结果只能说明当前片段不完整,不能说明模型输出了坏 JSON。

AI 流式响应把工具参数拆成多个片段,第一段解析失败而后续片段仍在传输

先验证边界:分块位置和 JSON 边界是两回事

排查时先把原始事件打印成带序号的短日志,不要只打印拼接后的结果。实际项目里至少保留 request_idtool_call_id、片段序号和字节长度:

type StreamPart struct {
    RequestID string
    ToolID    string
    Index     int
    Delta     string
}

type ToolBuffer struct {
    RequestID string
    ToolID    string
    Raw       strings.Builder
    Committed bool
}

这里用 strings.Builder 是为了避免每次追加都复制整个字符串。更重要的是,缓冲器的生命周期要绑定到一次工具调用,而不是绑定到连接本身;同一条连接上可能连续返回多个工具调用。

不要用最后一个字符猜结束

看到 } 并不等于可以提交。字符串值里也可能出现大括号,转义字符还会让简单的计数器失真。可靠做法是把累计文本交给 JSON 解码器,并把“暂时不完整”和“确定损坏”分开处理。

最小修复:累计片段,完整后再校验字段

下面的缓冲器只负责一件事:接收增量文本,返回“暂时等待”“解析成功”或“内容损坏”。业务工具不会看到半截参数。

package streamjson

import (
    "encoding/json"
    "errors"
    "strings"
)

var ErrWaiting = errors.New("json is not complete")

type WeatherArgs struct {
    City string `json:"city"`
    Unit string `json:"unit"`
}

type Buffer struct {
    text      strings.Builder
    committed bool
}

func (b *Buffer) Push(delta string) (WeatherArgs, error) {
    if b.committed {
        return WeatherArgs{}, errors.New("tool call already committed")
    }
    b.text.WriteString(delta)

    var args WeatherArgs
    dec := json.NewDecoder(strings.NewReader(b.text.String()))
    if err := dec.Decode(&args); err != nil {
        if strings.Contains(err.Error(), "unexpected end") || strings.Contains(err.Error(), "unexpected EOF") {
            return WeatherArgs{}, ErrWaiting
        }
        return WeatherArgs{}, err
    }
    if args.City == "" || (args.Unit != "celsius" && args.Unit != "fahrenheit") {
        return WeatherArgs{}, errors.New("invalid weather arguments")
    }
    b.committed = true
    return args, nil
}

生产代码还应检查解码器后面是否存在非空尾巴,避免把两个 JSON 对象黏在一起时误判成功。若上游协议能提供结束事件,也要把它当成必要条件之一:语法完整解决的是“能否解析”,结束事件解决的是“上游是否真的发完”。

Go 增量缓冲器接收片段,经过 JSON 解析和字段校验后只提交一次天气工具调用

定位根因:重连、重复片段和多工具调用

缓冲器能解决半截 JSON,但线上故障通常还藏着三类状态问题。

重连后不能无条件清空

如果断线后服务端从某个序号继续推送,清空缓冲区会丢掉前半段;如果服务端从头重播,则直接追加会把文本拼坏。接收端应依据事件序号或上游游标决定“续接”还是“重建”,并把选择写入日志。

同一个 tool_call_id 只能提交一次

提交工具前先查去重表,键可以是 request_id + tool_call_id。去重记录最好在工具任务入队前写入,并在失败时保存失败状态;不要用“调用成功后才记账”,否则超时重试会再次触发副作用操作。

多个调用要分开建缓冲器

不要把所有 delta 拼到一个全局字符串里。以 tool_call_id 建立 map,并限制单次参数的最大字节数,例如天气查询可以限制在 8 KB 内。超过上限立即停止接收并返回可观测错误,避免异常输出拖垮内存。

复查结果:用三组测试锁住边界

这个小组件不需要复杂压测,先用三组输入覆盖最容易回归的路径:

  • 把 JSON 拆在普通字符之间,确认前几次返回 ErrWaiting,完整后只提交一次。
  • 把分块切在中文 UTF-8 字节边界附近,确认上层按字符串事件传入,不自行按字节截断。
  • 重复推送同一个 tool_call_id,确认第二次被去重;传入多余字段或非法枚举值时,确认工具层不会收到参数。

验收日志可以只保留这样的关键信息:tool_id=weather-17 parts=3 bytes=36 state=committed。原始参数是否需要落盘,要按数据敏感等级处理,城市字段可以记录,用户地址、账号和令牌不要混在调试日志里。

常见问题:流式 JSON 处理还要注意什么

为什么不直接等完整响应再解析?

如果业务不需要边生成边展示,等完整响应确实更简单;但工具调用、长文本和超时控制常常需要流式接收。即便选择等待,也应在服务端设置总字节上限和截止时间。

字符串被拆开时会不会产生乱码?

如果上层事件已经是 UTF-8 字符串,按字符串追加通常不会有问题。风险来自自行按字节切片后立刻转成字符串,所以应让事件解析层负责恢复编码边界。

收到完整 JSON 就能调用工具吗?

还不够。需要检查结束事件、工具名称、字段类型、枚举值、参数大小和去重状态。语法正确只代表文本能被解析。

缓冲区应该什么时候释放?

成功提交、确定失败、超时或取消时都要释放;同时记录释放原因。对长连接场景,可用定时器清理超过截止时间仍未完成的调用。

把流式处理收敛成一个清晰的边界

接收层只负责事件顺序和文本拼接,解析层负责 JSON 完整性,校验层负责字段约束,工具层负责副作用。四层边界清楚后,意外 EOF 不会再直接变成业务重试,重复片段也不会悄悄变成重复任务。

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