☰
从零实现 C++ AI 大模型接入 SDK(八):OllamaLLMProvider 本地模型全量与流式实现
2026/10/9 10:21:45 网站建设 项目流程

目录

前言

一、本地模型接入

二、先让 Ollama 服务可以被项目访问

2.1 Linux 下的最小准备流程

2.2 先不用 C++,直接验证 /api/chat

三、/api/chat 请求怎样从统一结构映射出来

3.1 Message 仍然映射成 messages[]

3.2 max_tokens 在 Ollama 中要转换成 num_predict

3.3 全量和流式请求只在 stream 上分叉

四、全量响应:从本地 HTTP 到 message.content

4.1 本地服务使用 httplib::Client

4.2 网络失败和 HTTP 失败仍然要分开

4.3 返回值不能直接假设 message.content 一定存在

五、流式响应:这次不再是 SSE,而是 NDJSON

5.1 为什么“一行一个 JSON”仍然不能直接解析 data

5.2 buffer 只取完整行,最后半行必须留下

六、从一行 JSON 到 callback:content 和 done 各负责什么

6.1 先解析当前这一行

6.2 message.content 负责增量文本

6.3 done = true 才表示业务结束

6.4 HTTP 状态要在正文数据之前检查

6.5 连接结束不等于业务一定正确结束

七、测试 OllamaLLMProvider:先确认本地服务,再测两条链路

7.1 全量响应测试

7.2 流式响应测试

写在最后


前言

系列:从零实现 C++ AI 大模型接入 SDK,第八篇

项目源码:

AI-CHAT-SDKhttps://gitee.com/kuang-zhenting/my_ai_cpp_project

前面三篇,我们已经连续完成了三个云端 Provider:

DeepSeekProvider ↓ ChatGPTProvider ↓ GeminiProvider

它们底层使用的协议并不完全一样,但有一个共同前提:

模型运行在远程服务器上,C++ 程序通过网络调用云端 API。

上一章结束时,我们留下了一个新的问题:

如果模型不再运行在厂商服务器,而是直接运行在本机,ILLMProvider这一层还能不能继续成立?

这一篇就接入第四个 Provider:OllamaLLMProvider。

它和前三个 Provider 最大的不同,不是简单把域名换成127.0.0.1。

真正变化的是一整组协议细节:

  • HTTPS + API Key → 本地 HTTP,不需要 API Key;
  • 云端固定模型 → Ollama 中已经下载的本地模型;
  • SSE / Responses API 事件 → NDJSON,一行一个 JSON;
  • [DONE] / response.completed → done = true。

但是这些差异都应该被限制在 Provider 内部。

上层仍然只关心:

sendMessage(...) sendMessageStream(...)

以及统一的:

std::string callback(text, finished)

这一篇的重点不是写一份完整的 Ollama 运维手册,而是沿着当前项目真实实现,解决四个问题:

  1. 本地模型接入后,ILLMProvider为什么仍然可以复用;

  2. 公共Message和request_param怎样转换成 Ollama/api/chat请求;

  3. Ollama 的全量响应怎样提取message.content;

  4. NDJSON 流式数据怎样从网络chunk恢复成完整 JSON 行,再转成统一 callback。


一、本地模型接入

前三个 Provider 已经证明了一件事:

SDK 真正需要统一的,不是厂商协议,而是上层能够使用的能力。

ILLMProvider对外提供的是初始化、状态查询、全量发送和流式发送:

class ILLMProvider { public: virtual ~ILLMProvider() = default; virtual bool initModel( const std::map<std::string, std::string> model_config) = 0; virtual bool isAvailable() = 0; virtual std::string getModelName() const = 0; virtual std::string getModelDesc() const = 0; virtual std::string sendMessage( const std::vector<Message> &messages, const std::map<std::string, std::string> &request_param) = 0; virtual std::string sendMessageStream( const std::vector<Message> &messages, const std::map<std::string, std::string> &request_param, std::function<void(const std::string &, bool)> callback) = 0; };

这些能力并没有规定:

  • 必须使用 HTTPS;
  • 必须存在 API Key;
  • 必须访问公网;
  • 必须使用 SSE;
  • 必须使用某一种 JSON 字段。

因此模型从云端搬到本地,并不要求重新设计接口,真正需要改变的是 Provider 内部怎样完成协议适配。

当前源码中的OllamaLLMProvider仍然直接继承ILLMProvider:

class OllamaLLMProvider : public ILLMProvider { public: OllamaLLMProvider() = default; ~OllamaLLMProvider() override = default; explicit OllamaLLMProvider(const std::string &model_name); bool initModel( const std::map<std::string, std::string> model_config) override; bool isAvailable() override; std::string getModelName() const override; std::string getModelDesc() const override; std::string sendMessage( const std::vector<Message> &messages, const std::map<std::string, std::string> &request_param) override; std::string sendMessageStream( const std::vector<Message> &messages, const std::map<std::string, std::string> &request_param, std::function<void(const std::string &, bool)> callback) override; private: bool _isAvailable = false; std::string _model_name; std::string _model_desc; std::string _endpoint; };

所以从上层看,第四个 Provider 没有新增“本地模型专用发送接口”。

它仍然遵守相同的抽象,真正不同的是初始化配置。

前三个云端 Provider 的核心配置通常是:

api_key base_url

而当前 Ollama Provider 读取的是:

model_name model_desc endpoint

对应实现:

bool OllamaLLMProvider::initModel( const std::map<std::string, std::string> model_config) { _isAvailable = false; const auto name_iter = model_config.find("model_name"); if (name_iter == model_config.end() || name_iter->second.empty()) { ERR("OllamaLLMProvider init failed: model_name not found"); return false; } const auto desc_iter = model_config.find("model_desc"); if (desc_iter == model_config.end() || desc_iter->second.empty()) { ERR("OllamaLLMProvider init failed: model_desc not found"); return false; } const auto endpoint_iter = model_config.find("endpoint"); if (endpoint_iter == model_config.end() || endpoint_iter->second.empty()) { ERR("OllamaLLMProvider init failed: endpoint not found"); return false; } _model_name = name_iter->second; _model_desc = desc_iter->second; _endpoint = endpoint_iter->second; _isAvailable = true; return true; }

这里最值得注意的是:

本地模型不再依赖 API Key,但必须知道 Ollama 服务在哪里,以及要让 Ollama 加载哪个模型。

因此当前项目里的OllamaConfig也和APIConfig分开:

struct Config { virtual ~Config() = default; std::string _model_name; double _temperature = 0.7; int _max_tokens = 2048; }; struct APIConfig : public Config { std::string _api_key; }; struct OllamaConfig : public Config { std::string _modelDesc; std::string _endpoint{"http://127.0.0.1:11434"}; };

这已经把云端和本地的配置边界表达出来了:

共同配置 ├── model_name ├── temperature └── max_tokens 云端额外需要 └── api_key Ollama 额外需要 ├── modelDesc └── endpoint

接口没有变,变化的是 Provider 自己理解配置和协议的方式。


二、先让 Ollama 服务可以被项目访问

在写OllamaLLMProvider之前,需要先建立一个最基本的关系:

C++ 程序并不是直接打开模型权重文件,而是访问 Ollama 提供的本地 HTTP 服务。

当前项目中的链路是:

可以把它理解成四层:

ChatSDK / 测试代码 ↓ OllamaLLMProvider ↓ HTTP Ollama 本地服务 ↓ 本地模型

因此deepseek-r1:1.5b和 Ollama 不是同一个东西。

deepseek-r1:1.5b是 Ollama 管理和加载的一个模型;Ollama 则负责把模型能力暴露成稳定的本地接口。

2.1 Linux 下的最小准备流程

当前环境如果还没有安装 Ollama,可以使用:

curl -fsSL https://ollama.com/install.sh | sh

安装后检查:

ollama -v

如果使用 systemd 管理服务,可以启动并查看状态:

sudo systemctl start ollama sudo systemctl status ollama

项目当前测试使用的本地模型是:

deepseek-r1:1.5b

可以先拉取:

ollama pull deepseek-r1:1.5b

然后查看本地已有模型:

ollama list

对OllamaLLMProvider来说,最关键的前提只有三个:

  • Ollama 服务已经启动;
  • 目标模型已经存在;
  • 127.0.0.1:11434 可以访问。

这也正是当前 gtest 在注释里明确写出的测试前提。

2.2 先不用 C++,直接验证 /api/chat

当前项目调用的是:

POST /api/chat

可以先用一个最小请求验证本地服务:

curl http://127.0.0.1:11434/api/chat \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-r1:1.5b", "messages": [ { "role": "user", "content": "请用一句话介绍你自己" } ], "stream": false }'

全量模式下,我们真正关心的返回路径是:

message.content

而流式模式下,Ollama 会连续返回多条 JSON 数据,每条数据由换行分隔,最终通过:

"done": true

表示这一轮业务结束。

这个差异会直接决定后面sendMessageStream()的解析方式。

三、/api/chat 请求怎样从统一结构映射出来

上层仍然传入前面几篇已经使用过的:

std::vector<Message> messages; std::map<std::string, std::string> request_param;

Provider 的任务,就是把这些公共结构翻译成 Ollama 认识的请求体。

3.1 Message 仍然映射成 messages[]

当前公共Message使用:

std::string _role; std::string _content;

Ollama Provider 逐条转换:

Json::Value messages_array(Json::arrayValue); for (const auto &msg : messages) { Json::Value item(Json::objectValue); item["role"] = msg._role; item["content"] = msg._content; messages_array.append(item); }

这一层和 DeepSeek、Gemini 的思路很接近:

公共 Message ↓ Provider 协议映射 ↓ 厂商 / 服务端需要的 messages

所以历史消息、用户消息、assistant 消息仍然可以继续使用统一数据结构。

3.2 max_tokens 在 Ollama 中要转换成 num_predict

当前 SDK 对上层继续使用统一参数名:

temperature max_tokens

默认值:

double temperature = 0.7; int max_tokens = 2048;

读取方式:

auto it = request_param.find("temperature"); if (it != request_param.end()) { temperature = std::stod(it->second); } it = request_param.find("max_tokens"); if (it != request_param.end()) { max_tokens = std::stoi(it->second); }

但是进入 Ollama 请求体时,不是直接写:

"max_tokens": 2048

当前源码实际转换为:

Json::Value options(Json::objectValue); options["temperature"] = temperature; options["num_predict"] = max_tokens;

也就是:

SDK 的 max_tokens ↓ OllamaLLMProvider ↓ options.num_predict

这是 Provider 层非常典型的职责。

如果为了“统一”强迫所有后端都使用完全一样的 JSON Key,那么协议差异就会泄漏到上层。

更合理的做法是:

上层使用统一语义,Provider 负责把统一语义翻译成自己的协议字段。

3.3 全量和流式请求只在 stream 上分叉

全量请求:

Json::Value request_body(Json::objectValue); request_body["model"] = _model_name; request_body["messages"] = messages_array; request_body["options"] = options; request_body["stream"] = false;

流式请求:

Json::Value request_body(Json::objectValue); request_body["model"] = _model_name; request_body["messages"] = messages_array; request_body["options"] = options; request_body["stream"] = true;

所以两条链路的请求构造大部分可以保持一致:

model messages options

真正开始明显分叉的是响应处理。

四、全量响应:从本地 HTTP 到 message.content

先看全量链路。

4.1 本地服务使用 httplib::Client

前三个云端 Provider 访问的是 HTTPS。

Ollama 当前 Endpoint 是:

http://127.0.0.1:11434

所以源码直接创建:

httplib::Client client(_endpoint);

不是:

httplib::SSLClient

也不需要在请求里附加:

Authorization: Bearer ...

当前代码只需要向/api/chatPOST JSON:

client.set_connection_timeout(30, 0); client.set_read_timeout(300, 0); const auto result = client.Post("/api/chat", json_string, "application/json");

这里读取超时比普通网络请求留得更长,是有实际原因的。

本地 Ollama 第一次收到某个模型请求时,模型可能还没有处于已加载状态,服务需要先把模型加载起来,再开始推理。

因此:

"服务在本机"并不等于"响应一定瞬间返回"。

4.2 网络失败和 HTTP 失败仍然要分开

本地请求不需要 API Key,不代表可以省略错误处理。

第一层先判断网络请求本身有没有拿到结果:

if (!result) { ERR("Ollama request failed: {}", httplib::to_string(result.error())); return {}; }

第二层再判断服务返回的 HTTP 状态:

if (result->status != 200) { ERR("Ollama HTTP error, status: {}, body: {}", result->status, result->body); return {}; }

这两类错误的含义并不一样:

  • 没有 result → 连接失败、超时等网络层问题;
  • 有 result,但 status != 200 → 已经访问到 Ollama,但服务拒绝或处理失败。

例如:

  • Ollama 服务没有启动;

  • Endpoint 写错;

  • 端口无法访问;

  • 模型名称不存在;

  • 请求体不符合服务要求;

都可能在不同层级暴露出来。

4.3 返回值不能直接假设 message.content 一定存在

HTTP 200 只说明 HTTP 请求成功完成。

真正读取模型文本之前,当前源码还会解析 JSON:

Json::CharReaderBuilder reader_builder; Json::Value response_json; std::string parse_error; std::istringstream response_stream(result->body); if (!Json::parseFromStream( reader_builder, response_stream, &response_json, &parse_error)) { ERR("Ollama response parse failed: {}", parse_error); return {}; }

接着检查message:

if (!response_json.isMember("message") || !response_json["message"].isObject()) { ERR("Ollama response missing message object"); return {}; }

最后才检查content:

const Json::Value &message = response_json["message"]; if (!message.isMember("content") || !message["content"].isString()) { ERR("Ollama response missing message.content"); return {}; } const std::string reply = message["content"].asString(); return reply;

这里的防御性判断很重要。

如果直接写成:

return response_json["message"]["content"].asString();

代码虽然短,但一旦服务返回错误结构、字段缺失或者数据类型变化,问题就很难定位。

到这里,全量响应已经完成。

它和前三个 Provider 对上层的结果没有区别:

  • 底层:Ollama 本地 HTTP + message.content;
  • 上层:std::string。

五、流式响应:这次不再是 SSE,而是 NDJSON

如果只看“边生成边返回”,Ollama 的流式调用和 DeepSeek、Gemini 很像。

但从协议层看,它们并不是一回事。

前面处理 SSE 时,我们建立过一个非常重要的概念:

网络 chunk ≠ 完整 SSE event

因此要把 chunk 先放进buffer,再按照 SSE 事件边界恢复完整事件。

Ollama 仍然需要buffer,但原因发生了变化:

Ollama 的业务边界是一行 NDJSON,不是一个 SSE event。

NDJSON 可以理解成:

JSON\n JSON\n JSON\n ...

结构示意:

{"message":{"role":"assistant","content":"快速"},"done":false} {"message":{"role":"assistant","content":"排序"},"done":false} {"message":{"role":"assistant","content":""},"done":true}

每一行都可以独立解析成一个 JSON 对象。

这里没有:

data:

也没有:

[DONE]

结束状态直接写在 JSON 字段里:

"done": true

5.1 为什么“一行一个 JSON”仍然不能直接解析 data

看到 NDJSON 后,一个很自然的误区是:

既然一行就是一个 JSON,那content_receiver每调用一次,直接解析一次不就可以了吗?

仍然不可以。

因为 HTTP 底层交给content_receiver的仍然只是网络chunk。

一次回调可能发生:

半条 JSON

也可能发生:

一条完整 JSON + 下一条 JSON 的前半段

甚至可能一次包含:

多条完整 JSON

所以真正关系仍然是:

chunk ↓ buffer ↓ 按 \n 找完整行 ↓ 逐行解析 JSON

这和第五篇 SSE 的思想是一致的:

不要把网络传输边界当成业务协议边界。

只是这一篇的业务边界从:

SSE event

变成了:

NDJSON line

5.2 buffer 只取完整行,最后半行必须留下

当前源码先按真实长度追加:

buffer.append(data, data_length);

这里仍然不能写:

buffer += data;

因为data不保证以\0结尾,必须使用回调给出的真实长度。

然后不断寻找换行:

std::size_t line_end = 0; while ((line_end = buffer.find('\n')) != std::string::npos) { std::string line = buffer.substr(0, line_end); buffer.erase(0, line_end + 1); if (!line.empty() && line.back() == '\r') { line.pop_back(); } if (line.empty()) { continue; } // 后面解析当前完整 JSON 行 }

关键不是find('\n')本身,而是:

buffer.erase(0, line_end + 1);

只删除已经确认完整的一行。

如果buffer最后还剩:

{"message":{"content":"排

它没有换行,就不会进入解析。

这半截数据会继续留在buffer中,等下一次网络回调再接上后半段。


六、从一行 JSON 到 callback:content 和 done 各负责什么

把完整 NDJSON 行拿出来之后,才真正进入业务解析。

完整链路如下:

6.1 先解析当前这一行

当前源码每次都为完整行建立输入流:

Json::CharReaderBuilder reader_builder; Json::Value chunk_json; std::string parse_error; std::istringstream line_stream(line); if (!Json::parseFromStream( reader_builder, line_stream, &chunk_json, &parse_error)) { ERR("Ollama stream JSON parse failed: {}", parse_error); continue; }

注意这里的策略是:

单行解析失败 ↓ 记录日志 ↓ 跳过这一行 ↓ 继续处理后面的流数据

并没有因为一条 JSON 解析失败就立刻让整个连接中止。

6.2 message.content 负责增量文本

如果当前行包含:

message.content

就取出本次新增文本:

if (chunk_json.isMember("message") && chunk_json["message"].isObject()) { const Json::Value &message = chunk_json["message"]; if (message.isMember("content") && message["content"].isString()) { const std::string text = message["content"].asString(); if (!text.empty()) { full_response += text; if (callback) { callback(text, false); } } } }

这里继续维持前几篇已经建立的两条输出:

当前增量文本 ↓ callback(text, false)

同时:

所有增量文本 ↓ full_response += text

所以流式接口既能让上层实时显示,又能在函数结束后返回完整字符串。

6.3 done = true 才表示业务结束

Ollama 不使用[DONE]。

当前源码通过:

if (chunk_json.isMember("done") && chunk_json["done"].isBool() && chunk_json["done"].asBool()) { stream_finished = true; if (callback) { callback("", true); } }

把 Ollama 的协议结束字段翻译成 SDK 的统一结束语义:

done = true ↓ callback("", true)

这一步和 DeepSeek / Gemini 的:

[DONE] ↓ callback("", true)

本质上做的是同一件事。

Provider 把不同协议里的“结束”统一成上层认识的finished=true。

6.4 HTTP 状态要在正文数据之前检查

流式发送没有直接使用简单的client.Post()。

当前源码显式构造:

httplib::Request request; request.method = "POST"; request.path = "/api/chat"; request.body = json_string; request.set_header("Content-Type", "application/json");

然后设置响应头处理:

request.response_handler = [&](const httplib::Response &response) { response_status = response.status; if (response_status != 200) { request_error = "HTTP status " + std::to_string(response_status); return false; } return true; };

只有 HTTP 层允许继续后,content_receiver才处理正文。

这样可以避免把错误响应正文误当成正常 NDJSON 流来解析。

6.5 连接结束不等于业务一定正确结束

发送请求:

const auto result = client.send(request);

如果网络层失败:

if (!result) { ERR("Ollama stream request failed: {}", httplib::to_string(result.error())); return full_response; }

如果 HTTP 层已经记录错误:

if (!request_error.empty()) { ERR("Ollama stream failed: {}", request_error); return full_response; }

最后还要检查:

if (!stream_finished) { WARN( "Ollama stream ended without done=true, status: {}", response_status); if (callback) { callback("", true); } }

这和前几篇处理[DONE]、response.completed时的原则完全一致:

TCP/HTTP 连接结束只是传输结束,不自动等价于业务协议已经正常完成。

当前实现如果没有看到done=true,会记录警告,但仍然兜底发送一次:

callback("", true);

原因很实际:

如果上层界面已经进入“正在生成”状态,却永远收不到结束回调,UI 就可能一直停在生成中。

因此这里同时维护了两种状态:

HTTP / 网络是否结束

和:

Ollama 协议是否出现 done = true

不能混为一谈。


七、测试 OllamaLLMProvider:先确认本地服务,再测两条链路

当前测试代码已经分别覆盖:

OllamaLLMProviderTest.SendMessage OllamaLLMProviderTest.SendMessageStream

7.1 全量响应测试

测试先创建 Provider:

ai_chat_sdk::OllamaLLMProvider provider;

然后准备当前实现需要的三项配置:

std::map<std::string, std::string> config{ {"model_name", "deepseek-r1:1.5b"}, {"model_desc", "本地部署的 DeepSeek-R1 1.5B 推理模型"}, {"endpoint", "http://127.0.0.1:11434"} };

初始化并确认状态:

ASSERT_TRUE(provider.initModel(config)); ASSERT_TRUE(provider.isAvailable());

准备消息和公共请求参数:

std::vector<ai_chat_sdk::Message> messages{ {"user", "请用一句话介绍你自己"} }; std::map<std::string, std::string> request_param{ {"temperature", "0.7"}, {"max_tokens", "2048"} };

最后发送:

const std::string reply = provider.sendMessage(messages, request_param); ASSERT_FALSE(reply.empty());

这条测试验证的是:

初始化成功 + /api/chat 全量请求成功 + message.content 能被正确提取

7.2 流式响应测试

流式测试仍然使用同样的模型配置。

区别是传入 callback:

bool completed = false; const std::string full_reply = provider.sendMessageStream( messages, request_param, [&](const std::string &text, bool finished) { if (!finished) { std::cout << text << std::flush; return; } completed = true; std::cout << "\n[DONE]" << std::endl; });

最后验证:

ASSERT_FALSE(full_reply.empty()); ASSERT_TRUE(completed);

这里验证的是两个维度:

full_reply 非空 ↓ Provider 确实累积到了完整回复 completed == true ↓ 上层确实收到了结束回调

和上一篇 Gemini 测试不同,当前 Ollama 流式测试没有另外保存每个增量text,因此没有做:

EXPECT_EQ(full_reply, streamed_text);

所以当前测试重点是:

流式链路能返回完整结果,并且能够正确通知结束。

如果要只运行这一组测试,可以使用 gtest filter:

./test_LLM --gtest_filter=OllamaLLMProviderTest.*


写在最后

到这里,我们已经有四个 Provider:

底层差异已经非常明显:

DeepSeek → Chat Completions 风格 → SSE → [DONE] ChatGPT → Responses API → SSE event/data → response.completed Gemini → 当前项目使用兼容 Chat Completions 结构 → SSE → [DONE] Ollama → 本地 /api/chat → NDJSON → done = true

但是四个 Provider 对上层暴露的能力仍然可以保持一致:

Message[] request_param ↓ ILLMProvider ↓ std::string / callback(text, finished)

这正是前面抽象ILLMProvider的价值。

不过 Ollama 还带来了一个新的问题。

前三个云端 Provider 的模型名在当前代码里是固定的:

DeepSeekProvider::getModelName() → "deepseek-chat" ChatGPTProvider::getModelName() → "gpt-5.5" GeminiProvider::getModelName() → "gemini-3.5-flash"

但 Ollama 不一样。

本机可以同时存在:

deepseek-r1:1.5b qwen... gemma... 其他 Ollama 模型

所以OllamaLLMProvider的模型名称不能简单写死。

当前源码因此额外提供了:

explicit OllamaLLMProvider(const std::string &model_name);

实现很简单:

OllamaLLMProvider::OllamaLLMProvider( const std::string &model_name) : _model_name(model_name), _isAvailable(false) { }

为什么需要在initModel()之前就把名字放进去?

答案藏在下一层LLMManager的注册逻辑里。

LLMManager::registerProvider()注册时会先读取:

const std::string model_name = provider->getModelName();

然后拒绝空名称:

if (model_name.empty()) { ERR("cannot register provider with an empty model name"); return false; }

也就是说,后面的模型管理流程是:

先创建 Provider ↓ 注册 Provider ↓ 注册阶段就需要 model_name ↓ 再调用 initModel()

对于固定模型名的三个云端 Provider,这没有问题。

但对于模型名动态变化的 Ollama:

OllamaLLMProvider() ↓ 模型名还是空 ↓ 无法先注册

因此才有:

OllamaLLMProvider("deepseek-r1:1.5b")

让 Provider 在注册前就拥有模型身份。

这也是第八篇接入完成后自然出现的下一层问题:

现在四个 Provider 都能独立工作了,上层难道还要自己判断模型类型、自己保存四个 Provider 对象、自己决定把请求转给谁吗?

当然是不应该,所以下一篇开始进入LLMManager:

模型名称 ↓ 找到对应 Provider ↓ 统一初始化 ↓ 统一全量 / 流式转发

到那时,多态才会真正从“接口设计”走到“模型路由”。


这一篇,我们把第四个 Provider 接进了同一套 ChatSDK。

模型从云端搬到了本地,但上层接口没有被推翻;真正被替换的是 Provider 内部的协议实现:

  • 云端 HTTPS → 本地 HTTP;
  • API Key → 本地默认无需认证;
  • 统一 max_tokens → Ollama options.num_predict;
  • SSE → NDJSON;
  • [DONE] → done = true。

到这里,四个模型后端已经具备了相同的“可调用形状”。

下一步就不应该继续让业务代码直接面对四个 Provider,而是把它们统一交给LLMManager管理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询