目录
前言
一、本地模型接入
二、先让 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-SDK
https://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 运维手册,而是沿着当前项目真实实现,解决四个问题:
本地模型接入后,
ILLMProvider为什么仍然可以复用;公共
Message和request_param怎样转换成 Ollama/api/chat请求;Ollama 的全量响应怎样提取
message.content;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": true5.1 为什么“一行一个 JSON”仍然不能直接解析 data
看到 NDJSON 后,一个很自然的误区是:
既然一行就是一个 JSON,那
content_receiver每调用一次,直接解析一次不就可以了吗?
仍然不可以。
因为 HTTP 底层交给content_receiver的仍然只是网络chunk。
一次回调可能发生:
半条 JSON也可能发生:
一条完整 JSON + 下一条 JSON 的前半段甚至可能一次包含:
多条完整 JSON所以真正关系仍然是:
chunk ↓ buffer ↓ 按 \n 找完整行 ↓ 逐行解析 JSON这和第五篇 SSE 的思想是一致的:
不要把网络传输边界当成业务协议边界。
只是这一篇的业务边界从:
SSE event变成了:
NDJSON line5.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.SendMessageStream7.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管理。