1. 为什么 Ollama 的 OpenAI 兼容接口不是“开箱即用”,而是需要你亲手调通?

Ollama 的 OpenAI 兼容接口,是当前本地大模型落地中最容易被高估、也最容易被低估的一环。它既不是“装完就能跑通的黑盒”,也不是“必须从零手写 HTTP 请求的苦力活”。它的本质,是一个 精心设计的协议桥接层 ——目标是让那些为 OpenAI 云服务写好的代码,能以最小改动接入你本机运行的 Llama、Qwen 或 Gemma 模型。但这个“最小改动”背后,藏着三重现实落差。

第一重落差,是 协议兼容的“实验性”标签 。官方文档里白纸黑字写着:“OpenAI 兼容性是实验性的,可能会进行重大调整,包括破坏性变更。”这不是一句客套话。我亲眼见过团队在升级 Ollama 0.3.5 到 0.4.0 后, /v1/chat/completions 接口对 tools 字段的解析逻辑发生细微变化,导致一个依赖函数调用的自动化脚本连续三天返回 400 Bad Request ,而错误日志里只有一行模糊的 invalid tool call format 。排查过程不是看文档,而是抓包对比两个版本的请求体结构。这说明,所谓“兼容”,是功能层面的近似,而非协议层面的镜像。它不承诺语义一致性,只承诺字段名和基础结构的可识别性。

第二重落差,是 环境准备的“隐形门槛” 。很多教程一上来就贴 curl 命令,却忽略了一个致命前提:你的 Ollama 服务是否真的在 http://localhost:11434 上健康运行?是否已拉取了指定模型?是否模型名称拼写正确?我曾帮一位同事调试,他反复执行 curl http://localhost:11434/v1/models 返回空数组,最后发现是他在 Windows 上用 PowerShell 运行 ollama serve ,而 PowerShell 默认会将命令后台化,窗口一关服务就终止了。他以为服务在跑,其实根本没启动。这种问题不会报错,只会让你在 API 调用环节陷入无尽的 Connection refused 循环。所以,“调通 API”的第一步,永远不是写代码,而是用最原始的方式确认服务心跳。

第三重落差,是 开发者心智模型的错位 。当你用 openai Python SDK 时,你默认它会处理重试、超时、连接池、流式响应的 chunk 解析。但 Ollama 的兼容层,只负责把你的 JSON 请求转给本地模型,并把模型输出包装成 OpenAI 格式的 JSON 响应。它 不负责网络健壮性 。这意味着,如果你的本地模型加载慢、推理卡顿,或者你并发请求过高触发了 Ollama 的内部限流,SDK 就会直接抛出 ReadTimeout ConnectionResetError ,而不是像 OpenAI 官方服务那样返回一个带 retry-after 头的 429 Too Many Requests 。你必须自己在 SDK 外层加熔断、重试和降级逻辑。这就像把一辆手动挡汽车的油门和刹车踏板,硬接到了一辆自动挡汽车的电控系统上——物理接口能插上,但驾驶体验和故障模式完全不同。

因此,这篇博文不叫“Ollama OpenAI API 使用指南”,而叫“从 curl 到 OpenAI SDK 兼容接口”。这个“从…到…”的路径,不是技术栈的升级,而是认知的校准:从最底层、最不可靠的 curl 开始,亲手触摸每一个字节的流动,理解每一次失败的根源,再逐步封装进更高阶的 SDK。只有这样,当你的生产环境凌晨三点报警, curl -v 抓到一个 503 Service Unavailable 时,你才能立刻判断是模型崩了、Ollama 进程挂了,还是你的 Python 脚本里那个 timeout=30 的参数设得太小了。

提示:在开始任何调用前,请务必执行这三行命令,它们是你所有后续操作的“健康检查单”:

# 1. 确认 Ollama 服务进程正在运行(Linux/macOS)
ps aux | grep ollama
# 2. 确认服务端口可访问(返回 HTTP 200)
curl -I http://localhost:11434
# 3. 确认至少有一个模型已就绪(返回非空 JSON 数组)
curl http://localhost:11434/v1/models | jq '.models'

如果其中任意一行失败,停下来,解决它。这是唯一能避免后续所有“玄学错误”的方法。

2. curl:解剖 API 调用的每一根神经,看清所有失败的真相

curl 是调试任何 HTTP API 的黄金标准,因为它剥离了所有 SDK 的抽象层,让你直面协议本身。对于 Ollama 的 OpenAI 兼容接口, curl 不仅是起点,更是你排查问题时最值得信赖的“听诊器”。它能告诉你,问题究竟出在网络层、服务层,还是你的请求构造层。下面,我将带你用 curl 完成一次完整的、可复现的调用链路,并拆解每一个关键细节。

2.1 最简可行调用:验证服务与模型的双重心跳

我们从最基础的 /v1/models 端点开始。这不是为了获取模型列表,而是为了完成一次“端到端”的健康验证。

curl -X GET http://localhost:11434/v1/models \
  -H "Content-Type: application/json" \
  -v

注意,这里强制加了 -v (verbose)参数。它会输出完整的 HTTP 请求头、响应头和状态码。一个健康的响应应该长这样:

*   Trying 127.0.0.1:11434...
* Connected to localhost (127.0.0.1) port 11434 (#0)
> GET /v1/models HTTP/1.1
> Host: localhost:11434
> User-Agent: curl/8.5.0
> Accept: */*
> Content-Type: application/json
>
< HTTP/1.1 200 OK
< Content-Type: application/json
< Date: Mon, 15 Apr 2024 08:22:34 GMT
< Content-Length: 246
<
{"models":[{"name":"llama3.2:latest","model":"llama3.2:latest","modified_at":"2024-04-10T15:30:22.123456Z","size":3210987654,"digest":"sha256:abc123...","details":{"format":"gguf","family":"llama","families":["llama"],"parameter_size":"3.2B","quantization_level":"Q4_K_M"}}]}

关键观察点有三个:

  1. 连接成功 Connected to localhost (127.0.0.1) port 11434 表明网络层通畅。
  2. HTTP 状态码 < HTTP/1.1 200 OK 是成功的铁证。如果看到 404 Not Found ,说明你的 Ollama 版本太旧(< 0.3.0),不支持 /v1/ 前缀;如果看到 502 Bad Gateway ,说明 ollama serve 进程没起来或崩溃了。
  3. 响应体结构 {"models":[...]} 是一个合法的 JSON 对象,且 models 数组非空。如果数组为空 [] ,请立即执行 ollama list 确认模型是否真的存在,再执行 ollama pull llama3.2 拉取。

注意: -H "Content-Type: application/json" GET 请求中是冗余的,但加上它是一种好习惯,能确保你在后续 POST 请求中不会忘记。它不会造成任何负面影响。

2.2 核心调用: /v1/chat/completions 的完整请求体剖析

现在,我们进入核心。 /v1/chat/completions 是最常用的端点,其请求体结构必须严格遵循 OpenAI 的规范,否则 Ollama 会直接拒绝。让我们构建一个最精简、但完全合规的请求:

curl -X POST http://localhost:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2",
    "messages": [
      {
        "role": "user",
        "content": "你好,你是谁?"
      }
    ]
  }' \
  -v

这个看似简单的 JSON,背后有五个极易踩坑的细节,每一个都可能导致 400 Bad Request

  1. model 字段的精确性 "model": "llama3.2" 必须与 ollama list 输出的 NAME 列完全一致。Ollama 区分大小写和版本号。如果你执行的是 ollama pull llama3.2:q4_0 ,那么这里的值就必须是 "llama3.2:q4_0" ,而不是 "llama3.2" 。我曾因一个冒号之差,在终端里反复敲了二十分钟命令,直到用 curl http://localhost:11434/v1/models 把返回的 JSON 复制粘贴过来才解决。

  2. messages 数组的强制性 messages 必须是一个数组,哪怕只有一个消息。 "messages": {"role": "user", "content": "..."} (对象)是非法的,Ollama 会返回 400 并提示 messages must be an array 。这是一个典型的“JSON 结构错误”, curl -v 的响应体里会清晰地告诉你。

  3. role 字段的限定值 role 只能是 "system" "user" "assistant" "role": "human" "role": "client" 都会触发 400 system 角色用于设定模型行为, user 是你的输入, assistant 是模型的上一轮回复(用于多轮对话)。 curl -v 输出会明确指出哪个字段不合法。

  4. content 字段的类型 content 必须是字符串。 "content": 123 (数字)或 "content": null (空值)都会导致 400 。更隐蔽的坑是,如果你的字符串里包含了未转义的双引号 " ,整个 JSON 就会语法错误。此时 curl 会直接报错 curl: (3) URL using bad/illegal format or missing URL ,而不是返回 HTTP 错误。解决方案是使用单引号包裹整个 -d 参数,如上例所示,这样 shell 就不会去解析里面的双引号。

  5. -d 参数的引号陷阱 :这是 curl 新手最大的雷区。错误写法:

# ❌ 错误:双引号会被 shell 解析,导致 JSON 被破坏
curl -d "{"model": "llama3.2"}" ...
# ✅ 正确:用单引号包裹,shell 不做任何解析
curl -d '{"model": "llama3.2"}' ...

执行成功后,你会得到一个结构化的 JSON 响应,其中最关键的字段是 choices[0].message.content ,它就是模型的回复文本。 curl -v 模式会把整个响应体(包括头部和 body)都打印出来,你可以用 | jq '.choices[0].message.content' 来快速提取结果。

2.3 排查实战:当 curl 返回 400 时,如何像侦探一样定位根因

400 Bad Request curl 调用 Ollama 时最常见的错误。它不像 500 那样指向服务端崩溃,而是明确告诉你:“你的请求有问题,但我懒得告诉你具体哪错了。”这时, curl -v 就是你的福尔摩斯。下面是一个真实的排错案例。

现象 :执行以下命令,返回 400

curl -X POST http://localhost:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2",
    "messages": [
      {"role": "user", "content": "Hello!"}
    ],
    "temperature": 0.7,
    "max_tokens": 100
  }'

第一步:开启 -v ,捕获完整响应体

curl -v ... # 同上,只是加了 -v

在响应体末尾,你可能会看到:

{"error":{"message":"the model has reached its context window limit.","type":"context_length_exceeded","param":null,"code":400}}

哦,原来是上下文超限。但 max_tokens 才设了 100,怎么会超?这就引出了下一个关键点: max_tokens 控制的是 生成 token 的数量 ,而 context window 是指 模型能同时处理的总 token 数(输入+输出) 。Llama3.2 的上下文窗口是 8K,但你的 messages 数组里可能包含了一段超长的 system prompt,或者用户输入本身就很长。解决方案是减少输入长度,或使用 num_ctx 参数创建一个更大上下文的新模型。

第二步:如果响应体里没有详细错误信息,怎么办? 有时,Ollama 只返回一个空的 400 。这时,你需要祭出终极武器: 逐个注释掉请求字段,进行二分法排查 。先删掉 temperature max_tokens ,只留 model messages 。如果成功,说明问题出在可选参数上。再逐一恢复,就能精准定位是哪个参数的值不被接受。例如, "temperature": -0.1 是非法的(必须 >= 0), "max_tokens": 0 也是非法的(必须 > 0)。

第三步:检查 Ollama 的服务日志 在另一个终端里,执行 ollama serve (如果它没在后台运行),然后重新执行 curl 。服务端的控制台会实时打印出详细的错误堆栈。比如,它会告诉你:

time="2024-04-15T16:30:22+08:00" level=error msg="failed to unmarshal request" error="json: cannot unmarshal number into Go struct field ChatRequest.temperature of type float64"

这说明你传入的 temperature 是一个字符串 "0.7" ,而不是数字 0.7 。JSON 规范要求数字不能加引号。这个错误, curl -v 是看不到的,只有服务端日志能揭示。

提示:将 curl 命令保存为一个 .sh 文件(如 test_api.sh ),并在文件开头加上 set -x ,可以让你在执行时看到每一步展开后的实际命令,这对排查 shell 变量替换错误极其有用。

3. OpenAI SDK:从“能用”到“稳用”的四层封装与避坑指南

当你用 curl 成功调通了 API,下一步自然就是拥抱更高级、更易用的 openai Python SDK。但请注意,SDK 的“易用性”是一把双刃剑。它用优雅的语法糖掩盖了底层的复杂性,也让你更容易在不知不觉中掉进性能、可靠性或兼容性的深坑。下面,我将基于 openai==1.35.0 (当前最新稳定版)的实践,为你梳理出从“能用”到“稳用”的四层关键封装。

3.1 第一层:基础连接—— base_url api_key 的真实含义

SDK 的初始化代码非常简洁:

from openai import OpenAI
client = OpenAI(
    base_url='http://localhost:11434/v1/',
    api_key='ollama',  # required but ignored
)

这段代码里, base_url 是你必须精确配置的核心。它必须以 /v1/ 结尾,且协议必须是 http (不是 https ),因为本地服务默认不启用 TLS。一个常见的错误是写成 'http://localhost:11434' (缺少 /v1/ ),这会导致所有请求都打到根路径,返回 404

api_key='ollama' 这行注释着 required but ignored ,意思是 SDK 的接口设计强制要求你传一个 api_key ,但 Ollama 服务端压根不校验它。你可以填任何字符串, 'hello' '123' 甚至空字符串 '' 都行。它的存在,纯粹是为了满足 SDK 的签名要求,让你的代码能和调用真正的 OpenAI 服务无缝切换。 不要试图在这里填一个“密钥”,Ollama 没有密钥体系。

3.2 第二层:核心调用—— chat.completions.create() 的参数陷阱

client.chat.completions.create() 是最常用的函数。它的参数几乎和 curl 的 JSON 请求体一一对应,但有几个关键差异点,是新手最容易栽跟头的地方。

陷阱一: model 参数的“别名”魔法 Ollama 的模型名(如 llama3.2 )和 OpenAI 的模型名(如 gpt-3.5-turbo )是两套体系。如果你的现有代码里硬编码了 model="gpt-3.5-turbo" ,直接运行会报错 404 Model not found 。解决方案不是改代码,而是用 Ollama 的 cp 命令创建一个“软链接”:

ollama cp llama3.2 gpt-3.5-turbo

这条命令会在 Ollama 的模型仓库里创建一个名为 gpt-3.5-turbo 的新模型,它实际上指向 llama3.2 的数据。之后,你的 SDK 代码就可以完全不动地运行:

response = client.chat.completions.create(
    model="gpt-3.5-turbo", # ✅ 现在可以用了!
    messages=[{"role": "user", "content": "Hello!"}]
)

这比在代码里写 if os.getenv("ENV") == "local": model = "llama3.2" 要优雅得多,也更符合“环境隔离”的工程原则。

陷阱二: stream 参数的异步陷阱 stream=True 会返回一个生成器(generator),你需要用 for 循环来迭代:

# ✅ 正确:流式响应
stream = client.chat.completions.create(
    model="llama3.2",
    messages=[{"role": "user", "content": "讲个笑话"}],
    stream=True
)
for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="", flush=True)

但如果你不小心把它当成了普通对象来用:

# ❌ 错误:试图直接访问 .choices
print(stream.choices[0].message.content) # AttributeError!

就会报错。 stream 对象没有 choices 属性,只有 __iter__ 方法。这是 SDK 设计的一个“契约”:你选择了流式,就必须用流式的方式消费。

陷阱三: max_tokens 的“双刃剑”效应 max_tokens 在 SDK 中的作用和 curl 中完全一样,但它带来的副作用更隐蔽。如果你设得过大(比如 max_tokens=8192 ),而你的模型上下文窗口只有 4096,Ollama 会静默地截断输出,或者在极端情况下导致服务端内存溢出而崩溃。更糟的是,SDK 不会主动告诉你发生了截断,它只会返回一个 finish_reason="length" 的响应。你需要在业务逻辑里显式检查:

response = client.chat.completions.create(
    model="llama3.2",
    messages=[{"role": "user", "content": "..."}],
    max_tokens=2000
)
if response.choices[0].finish_reason == "length":
    print("警告:输出被截断,可能需要增加 max_tokens 或优化 prompt")

3.3 第三层:可靠性加固——超时、重试与熔断的必选项

正如前面所说,Ollama 的兼容层不提供任何网络健壮性保障。一个 requests.exceptions.ReadTimeout 异常,就足以让你的整个批处理任务中断。因此,必须在 SDK 外层加装“保险丝”。

超时设置: timeout 参数是生命线 SDK 的 timeout 参数接受一个 httpx.Timeout 对象,它有三个维度:

  • connect : 建立 TCP 连接的超时(通常 5-10 秒足够)。
  • read : 从 socket 读取数据的超时(最关键,设为 30-60 秒,因为本地模型推理可能很慢)。
  • write : 发送请求体的超时(通常很短,1-5 秒)。
from openai import OpenAI
from httpx import Timeout

client = OpenAI(
    base_url='http://localhost:11434/v1/',
    api_key='ollama',
    timeout=Timeout(connect=10.0, read=60.0, write=5.0)
)

没有这个 timeout ,你的程序在模型卡死时会无限期挂起。

重试策略: max_retries 是应对瞬时抖动的良药 网络抖动、模型加载延迟都可能导致短暂的 503 Service Unavailable 。SDK 内置了重试机制,只需一个参数:

client = OpenAI(
    base_url='http://localhost:11434/v1/',
    api_key='ollama',
    max_retries=3 # 默认是 2,建议设为 3
)

它会自动对 5xx 和部分 4xx 错误进行指数退避重试。但请注意,它 不会 400 Bad Request 重试,因为这是你的请求错误,重试一万次也没用。

熔断器: circuit_breaker 是防止雪崩的终极防线 当 Ollama 服务连续多次失败(比如模型崩溃、磁盘满),持续重试只会让情况更糟。这时,你需要一个熔断器,在检测到故障率超过阈值时,自动“跳闸”,在一段时间内直接拒绝所有请求,给服务喘息和恢复的时间。Python 生态中有成熟的库如 pydantic CircuitBreaker tenacity 。一个极简的实现是:

import time
from functools import wraps

class SimpleCircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout=60):
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.last_failure_time = 0
        self.state = "CLOSED" # CLOSED, OPEN, HALF_OPEN

    def call(self, func, *args, **kwargs):
        if self.state == "OPEN":
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = "HALF_OPEN"
            else:
                raise Exception("Circuit breaker is OPEN")

        try:
            result = func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise e

    def _on_success(self):
        self.failure_count = 0
        self.state = "CLOSED"

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.failure_count >= self.failure_threshold:
            self.state = "OPEN"

breaker = SimpleCircuitBreaker()

@wraps(client.chat.completions.create)
def safe_chat_completion(*args, **kwargs):
    return breaker.call(client.chat.completions.create, *args, **kwargs)

# 使用
response = safe_chat_completion(model="llama3.2", messages=[...])

3.4 第四层:生产就绪——日志、监控与可观测性

在生产环境中,你不能只关心“能不能跑”,更要关心“跑得怎么样”。一个完善的 SDK 封装,必须内置可观测性。

结构化日志:记录每一次调用的“DNA” 不要只用 print() 。使用 logging 模块,记录关键指标:

import logging
import time

logger = logging.getLogger(__name__)

def log_chat_call(model, messages, response, duration_ms):
    logger.info(
        "ChatCall",
        extra={
            "model": model,
            "input_tokens": estimate_tokens(messages), # 你需要自己实现
            "output_tokens": len(response.choices[0].message.content.split()),
            "duration_ms": duration_ms,
            "finish_reason": response.choices[0].finish_reason,
            "status": "success"
        }
    )

# 在调用前后
start = time.time()
response = client.chat.completions.create(...)
duration = (time.time() - start) * 1000
log_chat_call("llama3.2", messages, response, duration)

这些日志可以被 ELK 或 Loki 收集,帮你分析模型的平均响应时间、token 效率、失败率等。

Prometheus 监控:暴露关键指标 prometheus_client 库,暴露几个核心指标:

from prometheus_client import Counter, Histogram, Gauge

# 定义指标
REQUESTS_TOTAL = Counter('ollama_requests_total', 'Total Ollama requests', ['model', 'status'])
REQUEST_DURATION = Histogram('ollama_request_duration_seconds', 'Ollama request duration', ['model'])
MODEL_LOADED = Gauge('ollama_model_loaded', 'Whether a model is loaded', ['model'])

# 在每次调用前后更新
REQUEST_DURATION.labels(model="llama3.2").observe(duration)
REQUESTS_TOTAL.labels(model="llama3.2", status="success").inc()
MODEL_LOADED.labels(model="llama3.2").set(1) # 当模型拉取成功时

然后,你的运维同学就可以在 Grafana 里画出漂亮的仪表盘,实时看到 llama3.2 的 QPS、P95 延迟、错误率,以及它是否还活着。

提示:在 SDK 封装层,我习惯定义一个 OllamaClient 类,它继承自 OpenAI ,并把上述所有可靠性加固、日志、监控逻辑都封装进去。这样,业务代码只需要 from mylib import OllamaClient ,就能获得一个开箱即用的、生产就绪的客户端。

4. 深度实践:从 curl 到 SDK 的平滑迁移路径与真实项目复盘

理论终归要落地。下面,我将以一个真实的内部项目——“智能会议纪要生成器”为例,完整复盘我们是如何从 curl 的原始调试,一步步演进到一个稳定、可维护、可监控的 SDK 封装的。这个项目需要将长达一小时的语音转文字稿(约 15000 字),喂给本地模型,让它提炼出关键决策、待办事项和负责人。整个过程充满了挑战,也沉淀了大量经验。

4.1 阶段一: curl 验证——用最笨的办法,确认最核心的可行性

项目启动的第一天,我们没有写一行 Python,而是围坐在一台开发机前,用 curl 进行“压力测试”。

目标 :确认 llama3.2 能否在合理时间内(< 5 分钟)处理一个 5000 字的长文本。

步骤

  1. 准备一个 5000 字的模拟会议稿,保存为 meeting.txt
  2. 构造一个复杂的 system prompt,明确指令:
    curl -X POST http://localhost:11434/v1/chat/completions \
      -H "Content-Type: application/json" \
      -d '{
        "model": "llama3.2",
        "messages": [
          {
            "role": "system",
            "content": "你是一位专业的会议秘书。请严格按以下格式输出:1. 关键决策(用 - 开头);2. 待办事项(用 * 开头,并在末尾标注 @负责人);3. 无其他任何内容。"
          },
          {
            "role": "user",
            "content": "'"$(cat meeting.txt)"'"
          }
        ],
        "temperature": 0.1,
        "max_tokens": 2048
      }' \
      -o output.json \
      -w "Time: %{time_total}s\n"
    
    这里, $(cat meeting.txt) 是 shell 的命令替换,它会把文件内容原样插入到 JSON 的 content 字段中。 -o output.json 将响应体保存到文件, -w 则输出总耗时。

结果与教训

  • 耗时 :第一次运行花了 217 秒(3分37秒),远超预期。 -w 输出的 Time: 217.345s 让我们立刻意识到瓶颈不在网络,而在模型推理。
  • 输出质量 jq '.choices[0].message.content' output.json 显示,模型确实生成了格式正确的文本,但漏掉了 3 个关键点。这说明 temperature=0.1 太低,让模型过于“保守”,不敢做推断。
  • 关键发现 curl -w 参数是神器。它让我们在 5 分钟内就量化了核心性能指标,而不是靠感觉。后来,我们把这个 curl 命令写成了一个 benchmark.sh 脚本,每天自动运行,监控模型性能的衰减趋势。

4.2 阶段二:Python 原生 requests ——构建第一个可复用的胶水层

curl 验证可行后,我们用 requests 库写了一个最简陋的 Python 函数:

import requests
import json

def generate_summary(text: str) -> str:
    url = "http://localhost:11434/v1/chat/completions"
    payload = {
        "model": "llama3.2",
        "messages": [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": text}
        ],
        "temperature": 0.3,
        "max_tokens": 2048
    }
    response = requests.post(url, json=payload, timeout=(10, 300))
    response.raise_for_status()
    return response.json()["choices"][0]["message"]["content"]

这个函数解决了 curl 的两个痛点:一是可以复用,二是可以方便地处理异常( raise_for_status )。但它很快暴露出新问题:

  • 内存爆炸 :当 text 是 15000 字的长文本时, json=payload 会创建一个巨大的字符串,吃光了 16GB 内存。
  • 超时不可控 timeout=(10, 300) read 超时是 300 秒,但模型一旦卡死, requests 会一直等到超时,期间无法取消。

解决方案 :我们引入了 httpx 库( requests 的现代替代品),它原生支持异步和流式上传:

import httpx

def generate_summary_stream(text: str) -> str:
    url = "http://localhost:11434/v1/chat/completions"
    payload = {...} # 同上
    with httpx.Client(timeout=httpx.Timeout(connect=10.0, read=300.0)) as client:
        response = client.post(url, json=payload)
        response.raise_for_status()
        return response.json()["choices"][0]["message"]["content"]

httpx 的内存占用显著降低,且其 timeout 机制更精细。

4.3 阶段三: openai SDK 封装——拥抱生态,但绝不盲从

httpx 版本稳定运行一周后,我们决定迁移到 openai SDK。动机很纯粹:我们的前端团队也在用同一个 SDK 调用云端的 Claude,我们希望后端和前端的调用方式完全一致,降低协作成本。

迁移过程

  1. pip install openai
  2. 替换 import requests from openai import OpenAI
  3. generate_summary 函数重写为:
    client = OpenAI(base_url="http://localhost:11434/v1/", api_key="ollama")
    
    def generate_summary_sdk(text: str) -> str:
        response = client.chat.completions.create(
            model="llama3.2",
            messages=[{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": text}],
            temperature=0.3,
            max_tokens=2048
        )
        return response.choices[0].message.content
    

惊喜与惊吓

  • 惊喜 :代码量减少了 30%,且 temperature max_tokens 等参数名与 OpenAI 官方文档完全一致,新人上手零学习成本。
  • 惊吓 :上线第二天,监控告警: ollama_requests_total{model="llama3.2",status="error"} 突然飙升。排查发现,是 SDK 的 max_retries=2 导致的。当模型因内存不足偶尔 503 时,SDK 会重试两次,每次重试都消耗一次昂贵的长文本推理,最终导致整体 P95 延迟翻了三倍。我们立刻将 max_retries 改为 0 ,并把重试逻辑移到了业务层,根据错误类型( 503 重试, 400 不重试)做精细化控制。

4.4 阶段四:生产就绪封装—— OllamaService 类的诞生

最终,我们封装了一个 OllamaService 类,它成为了项目里所有 AI 调用的唯一入口:

class OllamaService
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐