目录前言一、第三个 Provider统一的是能力不是协议1.1 初始化继续沿用 api_key base_url二、GeminiProvider 沿用 Chat Completions 结构2.1 公共 Message 怎样变成 messages2.2 请求参数仍然由 Provider 自己解释三、全量响应从请求到 choices[0].message.content3.1 发送请求3.2 先检查网络再检查 HTTP 状态3.3 解析 JSON四、流式响应真正复用的是 SSE 处理骨架4.1 请求端只增加几个流式设置4.2 response_handler 只负责 HTTP 层4.3 content_receiver 先处理网络 chunk4.4 从 buffer 中拆完整事件五、从 data: 到 delta.content再到结束回调5.1 先清理行再找 data:5.2 [DONE] 不是普通 JSON5.3 普通事件读取 choices[0].delta.content六、流式结束6.1 正常结束6.2 网络层失败6.3 HTTP 失败6.4 连接结束但没收到 [DONE]七、三个 Provider 放在一起八、测试 GeminiProvider写在最后前言系列从零实现 C AI 大模型接入 SDK第六篇项目源码AI-CHAT-SDKhttp:// https://gitee.com/kuang-zhenting/my_ai_cpp_project 前面几篇已经完成了两套不同的模型接入。DeepSeekProvider让我们第一次把统一 Provider 接口真正跑起来Message ↓ Chat Completions 风格请求 ↓ 全量 JSON / SSE ↓ std::string / callback到了第六篇ChatGPTProvider又换成了另一套 Responses APIMessage ↓ input ↓ output[] / response.output_text.delta ↓ std::string / callback这一篇继续接入第三个 ProviderGeminiProvider。第三个 Provider 的意义已经不只是“SDK 再多支持一个模型”。因为现在我们可以进一步验证一件事当底层协议再次变化时上层的ILLMProvider接口能不能继续保持不变当前源码中的GeminiProvider使用的是 Chat Completions 兼容结构POST /v1/chat/completions messages temperature max_tokens stream全量响应从choices[0].message.content取文本流式响应则继续使用choices[0].delta.content并通过data: [DONE]结束一轮 SSE。这意味着它和 DeepSeek 在“数据形状”上很接近但它仍然是一个独立 ProviderAPI Key、Endpoint、模型名、错误信息和实际服务都封装在GeminiProvider内部。所以这一篇不会重新从头讲一遍 SSE而是重点看三件事第三个 Provider 怎样继续复用ILLMProviderGemini 的兼容请求怎样映射成当前项目的统一输入已经写过的全量 / 流式处理逻辑哪些可以复用哪些仍然必须单独核对。一、第三个 Provider统一的是能力不是协议先看GeminiProvider.h。当前源码里的类声明很简单#pragma once #include ILLMProvider.h namespace ai_chat_sdk { class GeminiProvider : public ILLMProvider { public: bool initModel( const std::mapstd::string, std::string model_config) override; bool isAvailable() override; std::string getModelName() const override; std::string getModelDesc() const override; std::string sendMessage( const std::vectorMessage messages, const std::mapstd::string, std::string request_param) override; std::string sendMessageStream( const std::vectorMessage messages, const std::mapstd::string, std::string request_param, std::functionvoid(const std::string , bool) callback) override; }; }它没有再定义一套_api_key、_endpoint和_isAvailable。这些字段已经放在ILLMProvider的protected区域protected: bool _isAvailable false; std::string _api_key; std::string _endpoint;所以三个云端 Provider 的基本形状开始稳定下来ILLMProvider ├── DeepSeekProvider ├── ChatGPTProvider └── GeminiProvider这里最值得注意的不是继承语法而是抽象边界。上层只关心能不能初始化模型叫什么能不能发全量消息能不能发流式消息它不应该关心底层叫 messages 还是 input文本藏在 choices 还是 output流式结束靠 [DONE] 还是事件类型这些都应该留在具体 Provider 内部。1.1 初始化继续沿用 api_key base_url当前GeminiProvider::initModel()读取auto it1 model_config.find(api_key); if (it1 model_config.end()) { ERR(api_key is not found in model_config); return false; } _api_key it1-second;然后读取auto it2 model_config.find(base_url); if (it2 model_config.end()) { _endpoint https://www.nodapi.com; } else { _endpoint it2-second; }最后_isAvailable true;这和前面的 Provider 保持了同一种初始化方式model_config ↓ api_key base_url ↓ Provider 内部状态当前源码的模型名由getModelName()固定返回std::string GeminiProvider::getModelName() const { return gemini-3.5-flash; }因此这一篇后面的请求都按当前项目实际使用的gemini-3.5-flash展开。二、GeminiProvider 沿用 Chat Completions 结构如果刚写完上一篇的ChatGPTProvider这里很容易产生一个误解既然都是大模型 API是不是请求 JSON 差不多并不是。上一篇当前项目使用的是POST /v1/responses而GeminiProvider当前使用POST /v1/chat/completions它的请求体重新回到了我们在 DeepSeek 中已经很熟悉的结构{ model: gemini-3.5-flash, temperature: 0.7, max_tokens: 2048, messages: [ { role: user, content: 你好 } ] }如果是流式请求再增加{ stream: true }这就是当前项目选择兼容接口后最大的工程价值之一只要目标服务提供兼容的 Chat Completions 形状我们已经写过的大量消息组织、全量解析和 SSE 处理思路都可以复用。但这里的“复用”不是直接复制完就结束。我们仍然要核对Endpoint请求路径模型名认证方式参数名全量返回结构流式增量结构结束标记错误响应。2.1 公共 Message 怎样变成 messages上层仍然传入std::vectorMessage messages;Provider 内部把它转换成 JSON 数组Json::Value messages_array(Json::arrayValue); for (const auto message : messages) { Json::Value msg; msg[role] message._role; msg[content] message._content; messages_array.append(msg); }于是 SDK 的公共结构Message{ _role, _content }就被转换成{ role: user, content: 你好 }这个过程再次说明Message是 SDK 的公共语言JSON 才是具体 Provider 的协议语言。2.2 请求参数仍然由 Provider 自己解释当前GeminiProvider支持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); }然后写入请求体request_body[model] getModelName(); request_body[temperature] temperature; request_body[max_tokens] max_tokens; request_body[messages] messages_array;到这里公共输入已经完成第一次转换Message request_param ↓ GeminiProvider ↓ Chat Completions 兼容 JSON三、全量响应从请求到 choices[0].message.content全量响应的实现可以分成四步构造 JSON ↓ 发送 POST ↓ 解析完整响应体 ↓ 提取 choices[0].message.content3.1 发送请求当前源码创建客户端httplib::Client client(_endpoint); client.set_connection_timeout(30, 0); client.set_read_timeout(60, 0);请求头httplib::Headers headers { {Authorization, Bearer _api_key}, {Content-Type, application/json} };然后发送auto response client.Post( /v1/chat/completions, headers, json_string, application/json );这里不要把_endpoint和接口路径混在一起理解。当前代码的完整请求由两部分组成_endpoint /v1/chat/completions因此base_url可以通过配置变化而 Provider 的协议路径仍然保持明确。3.2 先检查网络再检查 HTTP 状态client.Post()返回以后第一层判断是if (!response) { ERR(Failed to connect to Gemini API); return ; }这表示连 HTTP 响应都没有拿到。第二层才是 HTTP 状态码if (response-status ! 200) { ERR(Gemini API returned status: {}, body: {}, response-status, response-body); return ; }这两类错误要分开看没有 response → 网络 / TLS / 连接层问题有 response但 status ! 200 → 服务端已经响应只是请求失败。3.3 解析 JSON拿到完整响应后Json::Value response_json; Json::CharReaderBuilder reader_builder; std::string parse_errors; std::istringstream response_stream(response-body); if (!Json::parseFromStream( reader_builder, response_stream, response_json, parse_errors)) { ERR(Failed to parse Gemini response: {}, parse_errors); return ; }解析成功并不代表一定能直接访问response_json[choices][0][message][content]因为异常响应、字段变化或者空数组都有可能让这个路径不存在。所以当前源码先逐层检查if (response_json.isMember(choices) response_json[choices].isArray() !response_json[choices].empty()) { const Json::Value choice response_json[choices][0]; if (choice.isMember(message) choice[message].isMember(content)) { return choice[message][content].asString(); } }这条路径最终可以压缩成response_json ↓ choices[0] ↓ message ↓ content ↓ std::string如果结构不符合预期就记录ERR(Invalid Gemini response format);而不是继续用固定下标硬取。四、流式响应真正复用的是 SSE 处理骨架前面第五篇已经把 SSE 最难的一部分讲过了network chunk ≠ 完整 SSE event所以到了 Gemini这一篇不需要再从零解释 SSE。我们真正要确认的是Gemini 当前返回的 SSE 结构能不能继续走已经形成的 buffer → event → data → JSON → delta.content 这条链答案从当前源码来看是可以的。4.1 请求端只增加几个流式设置请求体增加request_body[stream] true;请求头增加{Accept, text/event-stream}同时不再直接使用简单的client.Post(...)而是手动构造httplib::Request req; req.method POST; req.path /v1/chat/completions; req.headers headers; req.body json_string;因为流式请求需要两个关键回调response_handler content_receiver4.2 response_handler 只负责 HTTP 层当前代码req.response_handler [](const httplib::Response response) { statusCode response.status; if (statusCode ! 200) { gotError true; errorMsg HTTP error: std::to_string(statusCode); ERR({}, errorMsg); return true; } return true; };这里即使状态码失败也没有立刻return false;原因很重要。如果现在就终止接收我们可能只能知道HTTP 401却拿不到服务端返回的详细错误正文。所以当前实现选择继续接收 response body让content_receiver把错误文本追加进errorMsg4.3 content_receiver 先处理网络 chunk真正的流式数据从这里进来req.content_receiver [](const char *data, size_t len, uint64_t, uint64_t) { ... };每次回调只代表网络层这一次交给了我们len个字节。它并不保证一次 callback 一条完整 SSE event。因此当前源码仍然使用std::string buffer;然后buffer.append(data, len);注意这里不是buffer data;因为data只是一个指针当前有效长度由len明确给出不应该假设它以\0结束。4.4 从 buffer 中拆完整事件当前实现同时寻找buffer.find(\n\n);和buffer.find(\r\n\r\n);然后选择先出现的事件边界。如果两种都没有找到if (eventEnd std::string::npos) { break; }这代表现在只有半截事件此时不解析也不删除。等下一次网络数据到来以后继续buffer.append(data, len);直到拼成完整 event。这套逻辑与 DeepSeek 流式实现高度相似。这也是代码复用之外更重要的一种复用我们已经形成了稳定的流式数据处理模型。五、从 data: 到 delta.content再到结束回调拿到完整 SSE event 以后还不能直接解析 JSON。因为 SSE 本身还有一层文本协议。一条事件可能是data: {choices:[{delta:{content:你好}}]}也可能是: keep-alive data: {choices:[...]}还可能是data: [DONE]因此代码先逐行读取std::istringstream eventStream(event); std::string line; while (std::getline(eventStream, line)) { ... }5.1 先清理行再找 data:如果使用\r\ngetline()后面可能留下\r所以先处理if (!line.empty() line.back() \r) { line.pop_back(); }空行和注释行跳过if (line.empty() || line[0] :) { continue; }只有以data:开头的行才进入 payloadif (line.rfind(data:, 0) 0) { std::string value line.substr(5); if (!value.empty() value.front() ) { value.erase(value.begin()); } payload value; }5.2 [DONE] 不是普通 JSONpayload 得到以后第一件事不是 JSON 解析而是判断if (payload [DONE]) { streamFinish true; notifyDone(); continue; }因为[DONE]不是 JSON。如果直接交给 JsonCpp当然会解析失败。5.3 普通事件读取 choices[0].delta.content不是[DONE]再进入 JSON 解析Json::Value chunk; Json::CharReaderBuilder readerBuilder; std::string errors; std::istringstream jsonStream(payload); if (!Json::parseFromStream( readerBuilder, jsonStream, chunk, errors)) { WARN(Gemini SSE JSON parse error: {}, errors); continue; }然后逐层确认if (chunk.isMember(choices) chunk[choices].isArray() !chunk[choices].empty() chunk[choices][0].isMember(delta) chunk[choices][0][delta].isMember(content)) { const std::string content chunk[choices][0][delta][content].asString(); fullResponse content; if (!content.empty() callback) { callback(content, false); } }这一步最终得到choices[0] ↓ delta ↓ content为什么仍然要一层一层检查因为流式事件不保证每一块都有content有些事件可能只有角色信息有些可能只有结束原因。因此“成功解析 JSON”不等于“这一块一定有新文本”。六、流式结束流式接口最容易出现的一种假象是终端已经看到回复文字于是我们就认为实现完成了实际上还差最后一层上层什么时候知道这一轮结束当前接口的 callback 定义是std::functionvoid(const std::string , bool) callback项目约定callback(content, false) → 有新的增量文本callback(, true) → 这一轮结束。所以GeminiProvider定义bool doneCallbackSent false;并统一封装auto notifyDone []() { if (!doneCallbackSent callback) { callback(, true); doneCallbackSent true; } };这样可以避免多个异常分支重复触发结束通知。6.1 正常结束读到data: [DONE]时streamFinish true; notifyDone();6.2 网络层失败真正发送请求auto result client.send(req);如果失败if (!result) { const auto error result.error(); ERR(Network error: {}, httplib::to_string(error)); notifyDone(); return ; }这里使用httplib::to_string(error)而不是把httplib::Error直接交给日志格式化库。6.3 HTTP 失败如果之前response_handler已经设置gotError true;请求结束后统一处理if (gotError) { ERR(Gemini stream request failed: {}, errorMsg); notifyDone(); return ; }6.4 连接结束但没收到 [DONE]最后还要判断if (!streamFinish) { WARN(Stream ended without [DONE] marker); notifyDone(); }这一步非常重要。因为TCP / HTTP 请求结束并不一定代表模型业务层正常结束。中途断网、服务端异常关闭都可能让请求提前结束。所以完整判断应该是有没有网络错误 ↓ 有没有 HTTP 错误 ↓ 有没有收到 [DONE] ↓ 上层是否已经收到结束回调而不是只看终端有没有打印出几段文字。七、三个 Provider 放在一起到这里SDK 已经有三个云端 Provider。它们可以放在一张表里看项目DeepSeekProviderChatGPTProviderGeminiProvider请求路径/v1/chat/completions/v1/responses/v1/chat/completions消息字段messagesinputmessages全量文本choices[0].message.contentoutput[].content[].textchoices[0].message.content流式增量choices[0].delta.contentresponse.output_text.deltachoices[0].delta.content结束语义[DONE]response.completed[DONE]从这张表里能看出一个很有意思的现象。GeminiProvider在协议形状上更接近DeepSeekProvidermessages choices delta [DONE]而ChatGPTProvider使用的是另一套input output event type response.completed但是站在 SDK 上层看三个 Provider 的使用方式仍然是provider-sendMessage(messages, request_param);或者provider-sendMessageStream( messages, request_param, callback );这就说明第三篇里设计的抽象开始真正站住了。Provider 的价值不是消灭厂商差异而是把厂商差异封装在统一能力之后。只要上层不直接依赖具体 JSON 字段以后增加新的 Provider 时业务层就不需要跟着重写。八、测试 GeminiProvider当前项目已经有两组测试GeminiProviderTest.SendMessage GeminiProviderTest.SendMessageStream全量测试的大致流程是ai_chat_sdk::GeminiProvider provider; const char *api_key std::getenv(GEMINI_KEY_API); ASSERT_NE(api_key, nullptr); std::mapstd::string, std::string config; config[api_key] api_key; ASSERT_TRUE(provider.initModel(config)); ASSERT_TRUE(provider.isAvailable()); std::vectorai_chat_sdk::Message messages; messages.emplace_back(user, 你是谁); std::string reply provider.sendMessage(messages, params); EXPECT_FALSE(reply.empty());流式测试则额外累计 callback 收到的内容std::string streamedText; auto writeChunk [](const std::string chunk, bool isDone) { if (!chunk.empty()) { streamedText chunk; INFO(chunk: {}, chunk); } if (isDone) { INFO([DONE]); } }; const std::string fullData provider-sendMessageStream( messages, requestParam, writeChunk ); EXPECT_FALSE(fullData.empty()); EXPECT_FALSE(streamedText.empty()); EXPECT_EQ(fullData, streamedText);写在最后到这里GeminiProvider的全量和流式两条链路都已经清楚了。第三个 Provider 接入以后我们现在得到的已经不只是三份 HTTP 请求代码而是一套逐渐稳定的模型适配方式ILLMProvider ↓ 具体 Provider 负责协议转换 ↓ 统一返回 std::string / callbackDeepSeek 和 Gemini 证明兼容协议可以复用大量工程结构ChatGPT 又证明即使底层换成完全不同的 Responses API上层接口仍然可以保持不变。下一步再接入本地模型时问题会进一步变化云端模型依赖 API Key 和远程 HTTPS如果模型直接运行在本机Provider 这一层还需要调整什么下一篇继续接入OllamaLLMProvider。