从零实现 C++ AI 大模型接入 SDK(六):ChatGPTProvider 与 Responses API 全量/流式实现
目录前言一、第二个 Provider 才真正检验抽象1.1 当前 ChatGPTProvider 的基本信息二、Responses API 的请求怎么构造2.1 Message[] 怎么变成 input[]2.2 SDK 的 max_tokens 怎么映射到 max_output_tokens2.3 参数读取失败时不让整个请求直接崩掉三、全量响应为什么不能只写一个固定下标3.1 第一层遍历 output Item3.2 第二层遍历 content part3.3 sendMessage() 的完整调用链3.4 错误为什么要分层判断四、流式响应复用的是切帧不是事件内容4.1 先从 buffer 里恢复完整 SSE block4.2 再把 event: 和 data: 分开4.3 为什么 event 名称一定要 trim五、三类关键事件各自负责什么5.1 response.output_text.delta实时交付增量文本5.2 response.output_item.done收集完整 Item 文本5.3 response.completed真正的业务完成标志六、把 sendMessageStream() 的完整链路串起来6.1 请求阶段stream true6.2 为什么这里手动构造 httplib::Request6.3 response_handler先确定 HTTP 是否成功6.4 content_receiver真正的数据处理入口6.5 连接结束不等于业务正常结束七、测试 ChatGPTProvider7.1 全量响应测试7.2 流式响应测试写到最后前言系列从零实现 C AI 大模型接入 SDK第六篇项目源码AI-CHAT-SDKhttps://gitee.com/kuang-zhenting/my_ai_cpp_project前两篇我们已经把DeepSeekProvider的两条核心链路跑通了sendMessage() ↓ 完整 JSON ↓ 一次性返回最终文本以及sendMessageStream() ↓ network chunk ↓ buffer ↓ 完整 SSE event ↓ delta.content ↓ callback到了这一篇项目要开始接入第二个 ProviderChatGPTProvider。这一步比“再接一个模型”更重要。因为只有DeepSeekProvider时我们还不能确定前面设计的ILLMProvider到底是真的通用还是只是把 DeepSeek 的接口重新包装了一层。第二个 Provider 接进来以后问题就变成了上层仍然只传Message、请求参数和 callback但底层已经换成另一套请求字段、另一套完整响应结构和另一套流式事件语义。我们的统一接口还能不能保持不变当前项目里的ChatGPTProvider使用POST /v1/responses请求体不再使用 DeepSeek 那套messages而是改成input全量响应不再读取choices[0].message.content而是遍历output[].content[]流式响应也不再等待[DONE]而是根据response.output_text.deltaresponse.output_item.doneresponse.completed。这些事件来判断“新增文本”“完整 Item”和“整轮完成”。所以这一篇真正要做的不是复制DeepSeekProvider.cpp而是完成两次转换SDK 公共输入→Responses API 请求格式Responses API 返回格式→SDK 统一的 std::string / callback。一、第二个 Provider 才真正检验抽象先回到第三篇设计的ILLMProvider。当前接口并没有规定每一家模型必须使用什么 JSON 字段只规定了“一个大模型 Provider 应该具备什么能力”class ILLMProvider { public: virtual ~ILLMProvider() default; virtual bool initModel( const std::mapstd::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::vectorMessage messages, const std::mapstd::string, std::string request_param) 0; virtual std::string sendMessageStream( const std::vectorMessage messages, const std::mapstd::string, std::string request_param, std::functionvoid(const std::string , bool) callback) 0; protected: bool _isAvailable false; std::string _api_key; std::string _endpoint; };对于 DeepSeek 和当前的 ChatGPT Provider 来说上层需要的能力确实一样初始化模型判断是否可用获取模型信息发送全量消息发送流式消息。真正不同的是 Provider 内部。DeepSeek 使用的是/v1/chat/completions messages max_tokens choices[0].message.content choices[0].delta.content [DONE]而当前ChatGPTProvider使用的是/v1/responses input max_output_tokens output[].content[].text response.output_text.delta response.output_item.done response.completed这也说明了 Provider 抽象最核心的一点统一的是“能力”不是厂商的 JSON 结构。如果我们把messages、choices、[DONE]这些字段直接暴露给上层那么接入第二家服务时上层代码就不得不跟着修改。现在这些差异都留在ChatGPTProvider内部上层仍然可以写provider-sendMessage(messages, request_param);或者provider-sendMessageStream(messages, request_param, callback);调用方式没有变。1.1 当前 ChatGPTProvider 的基本信息当前项目的头文件仍然很干净class ChatGPTProvider : 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; };initModel()读取两个配置api_key base_url如果没有显式传入base_url当前源码默认使用注意这里虽然没有使用openai原生网站但是中转站的API 文档与原生网站几乎一致除了base_url不同https://www.nodapi.com初始化成功后_isAvailable true;而当前项目的模型名由std::string ChatGPTProvider::getModelName() const { return gpt-5.5; }直接返回。这里要区分两个概念ChatGPTProvider是项目中的 Provider 类名表示这一层负责适配 ChatGPT / OpenAI 风格的服务gpt-5.5则是当前源码真正写入请求体的模型标识。后面进入LLMManager时上层真正用于路由的也是模型名而不是 Provider 类名。二、Responses API 的请求怎么构造ChatGPTProvider和DeepSeekProvider的第一个明显区别就是请求体。DeepSeek 阶段我们一直在构造{ model: deepseek-chat, messages: [...], temperature: 0.7, max_tokens: 2048 }当前ChatGPTProvider则需要构造类似{ model: gpt-5.5, input: [...], temperature: 0.7, max_output_tokens: 2048, stream: false }这里不能直接把messages改个名字就结束因为 SDK 对外仍然希望使用统一的std::vectorMessage所以 Provider 需要主动完成协议转换。2.1 Message[] 怎么变成 input[]当前源码把这部分单独封装成Json::Value buildInputArray(const std::vectorMessage messages) { Json::Value inputArray(Json::arrayValue); for (const auto message : messages) { Json::Value item(Json::objectValue); item[role] message._role; item[content] message._content; inputArray.append(item); } return inputArray; }这样做以后上层始终只负责提供Message(user, 你好) Message(assistant, 你好有什么可以帮助你)至于目标 API 叫messages还是input由 Provider 自己决定。这就是统一数据结构真正开始发挥作用的地方。2.2 SDK 的 max_tokens 怎么映射到 max_output_tokens这里还有一个很值得注意的设计。SDK 前面已经把最大输出长度统一叫做max_tokens所以ChatGPTProvider读取的仍然是const int max_output_tokens readIntParam(request_param, max_tokens, 2048);但真正写入 Responses API 请求体时字段名变成requestBody[max_output_tokens] max_output_tokens;也就是SDK 对外参数max_tokens ↓ Provider 内部适配 ↓ Responses APImax_output_tokens这种转换比让所有上层代码都跟着不同厂商改字段名更合理。2.3 参数读取失败时不让整个请求直接崩掉当前源码还把字符串参数转换封装成两个辅助函数double readDoubleParam( const std::mapstd::string, std::string params, const std::string key, double defaultValue);以及int readIntParam( const std::mapstd::string, std::string params, const std::string key, int defaultValue);例如调用方传了temperature abcstd::stod()会失败。当前实现不会让异常一路抛到外面而是记录警告并回退到默认值catch (const std::exception ) { WARN(Invalid double param {}: {}, use default {}, key, iter-second, defaultValue); return defaultValue; }这样参数解析和网络请求之间的边界会更清楚。三、全量响应为什么不能只写一个固定下标请求发出去以后全量响应又出现了第二处协议差异。DeepSeek 的全量文本我们已经很熟悉choices[0].message.contentResponses API 的文本则位于更深的一层结构中。可以先把它抽象成response └── output[] └── Item └── content[] └── content part ├── type └── text如果只为了当前某一次返回结果确实可以直接写root[output][0][content][0][text]但这样会把很多假设写死output 一定存在并且output 一定是数组一定只有第 0 个 Item 有文本content 一定存在并且content 一定是数组第 0 个 part 一定是 output_texttext 一定是字符串。只要其中某一层结构发生变化就会变得非常脆弱。所以当前源码单独实现了std::string extractOutputText(const Json::Value root)它不是直接取固定下标而是逐层检查。3.1 第一层遍历 output Item首先确认output存在而且确实是数组if (!root.isMember(output) || !root[output].isArray()) { ERR(output is not array); return {}; }然后遍历for (const auto outputItem : root[output])这里的outputItem可以理解成 Responses API 输出数组里的一个元素。当前项目不假设每个 Item 都一定是文本对象所以先判断if (!outputItem.isObject()) { continue; }3.2 第二层遍历 content part如果 Item 中存在数组形式的contentif (!outputItem.isMember(content) || !outputItem[content].isArray()) { continue; }再继续遍历for (const auto contentPart : outputItem[content])这里仍然不直接取text而是先看类型if (contentPart.get(type, ).asString() ! output_text) { continue; }只有当前 part 真的是output_text并且text确实是字符串时才累加if (contentPart.isMember(text) contentPart[text].isString()) { fullText contentPart[text].asString(); }所以这段代码真正表达的是output 中可能有多个 Item ↓ 每个 Item 中可能有多个 content part ↓ 只收集 type output_text 的文本 ↓ 把所有有效 text 拼成最终结果3.3 sendMessage() 的完整调用链有了请求转换和extractOutputText()以后sendMessage()的主线就比较清楚了。前半部分先检查 Providerif (!isAvailable()) { ERR(ChatGPT provider is not available); return {}; }然后构造请求体Json::Value requestBody(Json::objectValue); requestBody[model] getModelName(); requestBody[input] buildInputArray(messages); requestBody[temperature] readDoubleParam(request_param, temperature, 0.7); requestBody[max_output_tokens] readIntParam(request_param, max_tokens, 2048); requestBody[stream] false;序列化后创建客户端httplib::Client client(_endpoint); client.set_connection_timeout(60, 0); client.set_read_timeout(80, 0);认证头只放httplib::Headers headers { {Authorization, Bearer _api_key} };真正发送auto response client.Post( /v1/responses, headers, json_string, application/json);最后一个参数已经告诉cpp-httplib请求体类型是application/json所以当前实现没有再在headers中重复添加Content-Type。3.4 错误为什么要分层判断全量请求至少要区分三类问题。第一类是传输层错误if (!response) { ERR(OpenAI request failed at transport layer: {}, httplib::to_string(response.error())); return ; }这类问题通常发生在网络TLS连接代理超时第二类是 HTTP 层错误if (response-status ! 200) { ERR(OpenAI returned HTTP {}, body: {}, response-status, response-body); return ; }这时候连接其实已经成功建立只是服务端拒绝了当前请求。第三类才是 JSON 解析问题if (!Json::parseFromStream( readerBuilder, responseStream, responseJson, parseError)) { ERR(Failed to parse OpenAI response: {}, parseError); return {}; }最后再交给extractOutputText(responseJson)把厂商返回结构重新收口成一个普通的std::string到这里上层已经完全看不到output[]、content[]这些 Responses API 细节了。四、流式响应复用的是切帧不是事件内容全量响应跑通以后下一步就是sendMessageStream()。第五篇已经把一个最重要的结论讲透了网络 chunk 不等于一条完整 SSE event。这个结论在ChatGPTProvider中仍然成立。所以这些基础设施可以继续复用content_receiver ↓ streamBuffer.append(data, length) ↓ 寻找空行分隔符 ↓ 取出完整 SSE event block真正变化的是事件叫什么数据放在哪里什么事件代表新增文本什么事件代表本轮正常结束4.1 先从 buffer 里恢复完整 SSE block当前源码使用bool popNextSseBlock(std::string buffer, std::string block)它同时寻找\n\n以及\r\n\r\n代码会选择更早出现的分隔符const std::size_t lfpos buffer.find(\n\n); const std::size_t crlfpos buffer.find(\r\n\r\n);如果两种都没有找到return false;这说明当前缓冲区还只有“半条事件”继续等待下一次网络数据即可。找到以后block buffer.substr(0, pos); buffer.erase(0, pos delimiterLength);一条完整事件交给下一层剩余字节继续留在buffer中。这一部分和 DeepSeek 的核心思路完全一致。4.2 再把 event: 和 data: 分开Responses API 的 SSE 事件不只有data:当前实现还会读取event: response.output_text.delta data: {...}所以项目定义了一个很小的结构struct SseEvent { std::string type; std::string data; };然后bool parseSseEventBlock( const std::string block, SseEvent event)逐行解析。遇到event:就写入event.type遇到data:就写入event.data而id:、retry:或当前项目不需要的字段暂时忽略。4.3 为什么 event 名称一定要 trimSSE 行通常长这样event: response.completed注意冒号后面有一个空格。如果直接写event.type line.substr(6);得到的就可能是response.completed前面多一个空格。后面再判断if (eventType response.completed)就永远匹配不到。因此当前源码专门实现了std::string trim(const std::string value)并在读取event:与data:时统一清理首尾空白event.type trim(line.substr(6));这个细节很小但如果忽略它表面上会看到网络有数据JSON 也能解析真正的业务分支却一个都不进入。五、三类关键事件各自负责什么SSE event 被正确切出来以后下一步不能像 DeepSeek 那样只找choices[0].delta.content因为 Responses API 的流式结果本身就是事件驱动的。当前实现重点处理三类事件。5.1 response.output_text.delta实时交付增量文本第一类response.output_text.delta表示新产生了一小段文本。当前实现会读取eventJson[delta]确认它是字符串以后const std::string delta eventJson[delta].asString();然后做两件事callback(delta, false); deltaAccumulated delta;第一句负责实时体验。上层一收到 callback就可以把刚生成的文字立刻显示出来。第二句负责兜底完整结果。如果后面没有拿到完整 Item 文本所有 delta 拼起来仍然可以形成完整回复。5.2 response.output_item.done收集完整 Item 文本第二类response.output_item.done它携带的是一个已经完成的item。代码先确认if (eventJson.isMember(item))再调用extractTextFromDoneItem(eventJson[item])这个辅助函数和全量响应里的extractOutputText()很像也会遍历item.content[]只提取type output_text最后把完整文本加入fullResponse itemText;这里有一个很容易重复计算的地方。delta已经被实时回调给上层了output_item.done中的完整文本主要用于构造最终返回值。如果又把 Item 完整文本重新 callback 一遍就会导致界面出现你好 你好我是...这种重复输出。所以当前实现把两条职责分开delta → 实时 callback item.done → 完整文本收集5.3 response.completed真正的业务完成标志第三类response.completed收到它以后streamFinished true; callback(, true);这里和第五篇有一个非常关键的不同。DeepSeek 流式响应的业务结束标志是[DONE]当前 Responses API 实现则不是去找[DONE]而是确认response.completed因此“SSE 怎么切帧”可以复用“怎样判断业务完成”不能照搬。六、把 sendMessageStream() 的完整链路串起来现在把前面的局部逻辑重新放回完整函数中。6.1 请求阶段stream true前置检查包括if (!isAvailable()) { return {}; } if (!callback) { return {}; } if (messages.empty()) { return {}; }然后仍然用统一输入构造请求Json::Value requestBody(Json::objectValue); requestBody[model] getModelName(); requestBody[input] buildInputArray(messages); requestBody[temperature] temperature; requestBody[max_output_tokens] max_output_tokens; requestBody[stream] true;全量和流式请求真正的协议入口仍然是同一个POST /v1/responses6.2 为什么这里手动构造 httplib::Request流式模式需要同时配置response_handler content_receiver所以当前实现没有继续使用最简单的client.Post(...)而是手动构造httplib::Request req; req.method POST; req.path /v1/responses; req.body json_body; req.headers headers;其中请求头包含{Authorization, Bearer _api_key}, {Content-Type, application/json}, {Accept, text/event-stream}6.3 response_handler先确定 HTTP 是否成功响应头到达以后req.response_handler [](const httplib::Response response) { statusCode response.status; if (response.status ! 200) { gotHttpError true; ERR(OpenAI stream returned HTTP {}, response.status); } return true; };即使状态码不是 200这里仍然返回true。原因是我们还希望继续读取错误响应体400 错误 JSON 401 认证说明 ...这样日志不会只有一个状态码。6.4 content_receiver真正的数据处理入口每次收到网络数据以后streamBuffer.append(data, dataLength);然后循环while (popNextSseBlock(streamBuffer, eventBlock))只要 buffer 里还有完整事件就继续处理。完整事件先变成SseEvent.type SseEvent.datadata再反序列化成 JSONJson::parseFromStream(...)最后根据eventType分派if (eventType response.output_text.delta) { ... } else if (eventType response.output_item.done) { ... } else if (eventType response.completed) { ... }这里还有一个兜底设计。正常情况下事件类型来自event: response.output_text.delta但如果event:行缺失而 JSON 内部有{ type: response.output_text.delta }当前代码还会尝试if (eventType.empty() eventJson.isMember(type) eventJson[type].isString()) { eventType eventJson[type].asString(); }这让事件分派更稳健一些。6.5 连接结束不等于业务正常结束client.send(req)返回以后还不能立刻return fullResponse;当前实现继续做三层检查。第一层传输是否成功if (!result) { ERR(OpenAI stream failed at transport layer: {}, httplib::to_string(result.error())); return {}; }第二层HTTP 是否成功if (gotHttpError || statusCode ! 200) { ERR(OpenAI stream HTTP error {}, body: {}, statusCode, errorBody); return {}; }第三层也是最容易漏掉的一层if (!streamFinished) { ERR(Stream ended without response.completed event); return {}; }因为TCP 连接结束只能说明“这条连接不再继续传数据”。它不能证明模型已经按照协议正常生成完成代理断开、读取超时、服务中断都可能让连接结束。所以业务层还必须确认response.completed最后再决定最终返回值if (fullResponse.empty()) { fullResponse deltaAccumulated; } return fullResponse;也就是优先使用 output_item.done 收集的完整文本 ↓ 如果没有 ↓ 退回到所有 delta 的拼接结果这时函数同时满足两个需求callback → 负责实时展示return fullResponse → 负责最终完整结果。七、测试 ChatGPTProviderProvider 写完以后至少要验证两条链路全量响应流式响应。当前测试使用的 API Key 环境变量是export CHATGPT_KEY_API你的 API Key不要把真实 Key 直接写进代码或提交到仓库。7.1 全量响应测试一个最小测试可以写成TEST(ChatGPTProviderTest, SendMessage) { ai_chat_sdk::ChatGPTProvider provider; const char *api_key std::getenv(CHATGPT_KEY_API); ASSERT_NE(api_key, nullptr); std::mapstd::string, std::string config; config[api_key] api_key; config[base_url] https://www.nodapi.com; ASSERT_TRUE(provider.initModel(config)); ASSERT_TRUE(provider.isAvailable()); std::mapstd::string, std::string params; params[temperature] 0.7; params[max_tokens] 2048; std::vectorai_chat_sdk::Message messages; messages.emplace_back(user, 你是谁); std::string reply provider.sendMessage(messages, params); std::cout reply: reply std::endl; EXPECT_FALSE(reply.empty()); }这里示例统一使用max_tokens因为当前ChatGPTProvider.cpp实际读取的是这个 SDK 通用键再映射到请求 JSON 的max_output_tokens当前TEST/test_LLM.cpp中的 ChatGPT 测试把参数写成了params[max_output_tokens] 2048;但 Provider 并不会读取这个键而会回落到默认值2048。由于两边刚好都是2048测试表面上仍然可能正常工作这也是一个很容易被忽略的参数一致性问题。7.2 流式响应测试流式测试除了“有内容”还应该验证callback 拼出来的文本 sendMessageStream() 最终返回的完整文本核心写法可以保持和第五篇一致std::string streamedText; const std::string fullData provider-sendMessageStream( messages, requestParam, [](const std::string chunk, bool isDone) { if (!chunk.empty()) { streamedText chunk; INFO(chunk: {}, chunk); } if (isDone) { INFO([DONE]); } }); EXPECT_FALSE(fullData.empty()); EXPECT_FALSE(streamedText.empty()); EXPECT_EQ(fullData, streamedText);这里 callback 中打印的[DONE]只是我们上层测试在isDone true时使用的日志文本。它不代表 Responses API 本身返回了data: [DONE]。底层真正触发isDone true的是response.completed这个区别不要混在一起。// 测试ChatGPTProvider的SendMessageStream测试用例 TEST(ChatGPTProviderTest, SendMessageStream) { const char *apiKey std::getenv(CHATGPT_KEY_API); ASSERT_NE(apiKey, nullptr); std::mapstd::string, std::string config {{api_key, apiKey}, {base_url, https://www.nodapi.com}}; std::mapstd::string, std::string requestParam {{temperature, 0.7}, {max_output_tokens, 2048}}; auto provider std::make_sharedai_chat_sdk::ChatGPTProvider(); ASSERT_NE(provider, nullptr); ASSERT_TRUE(provider-initModel(config)); ASSERT_TRUE(provider-isAvailable()); std::vectorai_chat_sdk::Message messages {{user, 你好}, {assistant, 你好很高兴见到你。有什么可以帮助你的吗}, {user, 简单介绍下遮天的剧情}}; 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); INFO(fullData.size(): {}, fullData.size()); INFO(streamedText.size(): {}, streamedText.size()); INFO(fullData: [{}], fullData); INFO(streamedText: [{}], streamedText); EXPECT_FALSE(fullData.empty()); EXPECT_FALSE(streamedText.empty()); EXPECT_EQ(fullData, streamedText); }写到最后这一篇完成以后SDK 已经不再只有一个DeepSeekProvider。现在同一套ILLMProvider下面已经可以存在DeepSeekProvider ChatGPTProvider它们对上层提供的能力一致initModel() isAvailable() getModelName() getModelDesc() sendMessage() sendMessageStream()但内部协议完全可以不同,真正值得记住的不是某一个字段名而是 Provider 的职责边界上层只表达“我要发送消息”Provider 负责把统一输入翻译成厂商协议再把厂商返回翻译回统一结果。第二个 Provider 能接进来以后前面设计的统一接口才算真正开始经受多模型场景的检验。下一篇继续沿着这条路线接入GeminiProvider。有了 DeepSeek 和 ChatGPT 两次完整适配以后我们会更容易看清哪些逻辑应该继续留在具体 Provider 中哪些能力已经适合交给更上层的模型管理模块统一处理。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →