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

Ollama API 怎样流式读取模型响应

来源:17golang原创

时间:2026-11-03 07:22:55 167浏览 收藏

读取 Ollama 原生 API 的流式响应时,应把响应体当作 application/x-ndjson:按换行读取一条 JSON,解析其中的文本增量并及时显示,直到收到 done: true。不要对整个响应体只调用一次 JSON 解析,也不要把一次网络读取等同于一条消息。HTTP 200 后仍可能出现带 error 的错误记录。

官方地址:https://docs.ollama.com/api/streaming

保护增量文本与完成状态

对一个本地写作助手,真正要保留的是两类资产:已经显示的文本,以及这段文本是否完整结束的状态。它们不能合成一个“有文字就成功”的布尔值。请求中途断开时,界面可能已有几句话,但这几句话只能标记为未完成草稿。

本文只使用原生 /api/generate,文本字段为 response,请求中显式设置 stream: true。/api/chat 的字段结构不同,不能直接照搬;OpenAI 兼容接口的事件格式也不在本文范围内。

本地请求地址采用 http://127.0.0.1:11434/api/generate。运行示例前应已启动 Ollama,并准备一个本机可用的模型,模型名通过环境变量 OLLAMA_MODEL 传入。本文不指定下载模型,也不假设读者已经拥有某个标签。

识别分帧和输出渲染风险

网络层读取的块大小由传输和客户端缓冲决定:一块可能包含多个 JSON 记录,也可能只含半条记录。NDJSON 的记录边界是换行,JSON 字符串里的换行会按 JSON 规则转义。因此本例使用逐行读取,把完整的一行交给解析器。

UTF-8 也可能在任意网络块中被拆开;直接对每个随意分块调用独立的解码操作,可能误报编码错误。逐行累积字节后再严格解码,避免把传输分块误认为字符边界。增量文字不等于一个词或一个 token,不应按照“每条记录恰好一个 token”计算进度。

HTTP响应字节与NDJSON记录及文本字段的静态关系
图1:静态数据结构说明图。字节缓冲、换行记录和 JSON 字段属于不同层级,连线不表示实测网络分包。

若把文字放进网页,应通过文本节点展示,或经受控的 Markdown 渲染与清理流程;不要把原始模型文本直接写成可执行 HTML。以下命令行示例只是把文本写到 stdout,并不提供网页渲染器。

区分请求失败与半途失败

情况读取逻辑业务状态
HTTP 非成功状态处理 HTTP 异常请求失败
流中含 error 字段停止当前读取并关闭响应生成失败,已有文本保留为未完成
EOF 前未见 done=true报告缺少完成记录中断,不能算成功
收到 done=true处理本条文本后结束流式生成结束,另看 done_reason

官方错误文档说明,流开始后发生错误不会再改变原先的 HTTP 状态码。因此只检查 HTTP 200、随后忽略 error 对象,会漏掉真正的半途失败。完成记录也不一定代表回答符合业务要求:例如生成受到长度限制时,仍应结合结束原因提示用户。

用逐行解析和完成标记控制响应

下面的 Python 示例只依赖标准库。请求、读取和结束处理都在同一个上下文中,异常、用户中断和正常结束均会退出 with 块并关闭响应。它限制单行字节数和累计文本字符数,避免客户端无界缓冲;限制数值是示例的业务策略,不是 Ollama 协议上限。

import json
import os
import socket
import sys
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

MAX_LINE_BYTES = 1024 * 1024
MAX_TEXT_CHARS = 100_000

def consume(model, prompt):
    request = Request(
        "http://127.0.0.1:11434/api/generate",
        data=json.dumps({
            "model": model,
            "prompt": prompt,
            "stream": True,
        }, ensure_ascii=False).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    total_chars = 0
    completed = False
    reason = None

    # timeout 约束阻塞操作,不是整段生成的总时长预算
    with urlopen(request, timeout=60) as response:
        while True:
            # 限长读取;超长记录直接报错,不继续拼接成无限大对象
            raw = response.readline(MAX_LINE_BYTES + 1)
            if not raw:
                break
            if len(raw) > MAX_LINE_BYTES:
                raise RuntimeError("单条记录超过客户端上限")
            if not raw.strip():
                continue

            part = json.loads(raw.decode("utf-8"))
            if not isinstance(part, dict):
                raise RuntimeError("流记录必须是 JSON 对象")
            if "error" in part:
                # 对外只报固定说明,避免把服务端原文写入公共日志
                raise RuntimeError("服务端返回流内错误,已输出文本不完整")

            text = part.get("response", "")
            if not isinstance(text, str):
                raise RuntimeError("response 字段必须是字符串")
            total_chars += len(text)
            if total_chars > MAX_TEXT_CHARS:
                raise RuntimeError("累计文本超过客户端上限")

            # 先输出本条文本,避免丢掉完成记录可能携带的末尾文字
            sys.stdout.write(text)
            sys.stdout.flush()
            if part.get("done") is True:
                completed = True
                reason = part.get("done_reason")
                break

    if not completed:
        raise RuntimeError("连接结束但没有完成记录,答案不完整")
    return reason

if __name__ == "__main__":
    try:
        # 使用本机已有模型;不在源码里保存密钥或私有服务地址
        selected_model = os.environ["OLLAMA_MODEL"]
        end_reason = consume(
            selected_model,
            "为海边图书角写三条简短的阅读活动建议,不编造日期和价格。",
        )
        print("\n生成结束,原因:" + str(end_reason), file=sys.stderr)
    except HTTPError as exc:
        print("\n请求失败,HTTP 状态:" + str(exc.code), file=sys.stderr)
        sys.exit(1)
    except (URLError, TimeoutError, socket.timeout, OSError,
            ValueError, RuntimeError, KeyError):
        # stdout 中可能已存在部分文字;错误不能将其标成完整回答
        print("\n读取失败,已显示内容应视为未完成草稿。", file=sys.stderr)
        sys.exit(1)
    except KeyboardInterrupt:
        print("\n用户取消,保留已显示草稿。", file=sys.stderr)
        sys.exit(130)

将代码保存为 stream_answer.py,在环境中设置已有模型的名称后运行。stdout 输出文本,stderr 输出结束或错误状态;代码没有在本文中实际执行,也没有附上虚构模型回答。

# OLLAMA_MODEL 应由当前环境设置为本机已有的模型名
python3 stream_answer.py

若所选模型单独返回 thinking 字段,本例不会把它混入答案,只输出 response。把思考内容展示成另一种产品状态,需要另行处理,不应把它当作正文增量拼接。连接关闭也不代表模型一定立即卸载,模型驻留配置与客户端响应关闭是不同职责。

记录结束原因与异常状态

应用至少应区分生成中、已结束、失败和用户取消。可以记录业务请求编号、收到的记录数、累计字数、是否收到完成记录以及结束原因;不必为了排障把完整提示词和生成文本写进公共日志。网页断开或用户取消时,转发服务也应结束相应的上游读取。

完成标记与响应关闭及业务状态的静态依赖
图2:静态依赖说明图。完成记录、流内错误和资源关闭是不同判断维度;不是运行结果或时序流程。

不要把 timeout=60 解读为整个回答最多生成 60 秒:本例是阻塞式标准库客户端,网络操作超时和总时长预算不是一回事。需要严格总时长与即时取消的服务,应采用支持取消和明确 deadline 的客户端设计。

同样,不要在已经显示部分回答后无提示地重试并继续追加。重新生成可能给出不同开头,直接拼接会制造重复或语义断裂。重试应作为新的生成尝试,明确清空、替换或保留旧草稿。

用合成片段判断读取逻辑

下面是人为构造的协议片段,用来解释逻辑,不是真实模型输出。第一条提供正文增量,第二条完成并携带末尾文字,因此阅读器应输出“先列提纲。”并记录结束原因。

{"response":"先列","done":false}
{"response":"提纲。","done":true,"done_reason":"stop"}

若第二条换成 {"error":"example failure"},则不能宣称第一条文字已经构成完整答案;若第二条完全缺失,EOF 也不能替代 done。严格 JSON 记录不支持注释,因此片段中的字段说明放在正文里。

为什么还是最后一次性显示?除客户端有没有 flush,还需考虑服务端、反向代理或前端读取层是否缓冲;curl 的显示选项也不能替代应用中的逐条解析。

能不能直接读取 data: 开头的行?本文原生接口使用 NDJSON,没有 SSE 的 data: 前缀。不要混用不同接口的解析器。

stream=false 怎么处理?它返回一个完整 JSON 对象,适合不需要增量显示的场景;处理方式应与流模式明确分开。

官方生成接口:https://docs.ollama.com/api/generate;流内错误规则:https://docs.ollama.com/api/errors。

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