用 C++ 封装一个可替换的大模型调用 SDK:接口契约、流式解析与错误分层
很多团队第一次接入大模型时会把 HTTP 请求直接写进业务代码控制器里拼 JSON服务层里写鉴权头前端需要流式输出时又临时加一套回调。短期看能跑长期看会出现三个问题。第一模型供应方、接口路径、鉴权方式、超时策略可能变化。如果调用逻辑散落在业务模块里替换一次模型就要改很多地方。第二错误语义不清晰。网络超时、鉴权失败、限流、模型内容为空、JSON 解析失败都被包装成“调用失败”排查成本很高。第三流式输出和普通输出经常混在一起导致业务层既要理解 HTTP又要理解增量消息格式。更稳妥的做法是先写一个小而清晰的 C SDK业务层只面对统一的ChatClient接口SDK 内部负责配置、请求、响应解析、错误分类和可观测日志。它不需要一开始就做成复杂框架但必须把边界划清楚。设计目标这个最小 SDK 只解决四件事用配置描述模型接入点而不是在代码里硬编码地址和密钥。对业务暴露同步调用和流式调用两个入口。把 HTTP、JSON 和供应方响应格式限制在 SDK 内部。给调用方返回可判断的错误类型而不是只返回字符串。边界也要明确本文示例不声称兼容任何特定平台的全部能力不实现函数调用、多模态输入、重试队列或连接池。真实项目可以在这个骨架上扩展但不应该把第一版 SDK 写成无法验证的大而全封装。原理拆解一个模型调用 SDK 通常可以拆成五层。配置层读取BASE_URL、API_KEY、MODEL、TIMEOUT_MS等参数。密钥必须来自环境变量不能写进源码、配置仓库或日志。传输层负责发起 HTTP 请求。C 项目里可以选择 libcurl、Boost.Beast 或项目已有网络库。为了降低示例复杂度下面使用 libcurl。协议层负责构造请求 JSON 和解析响应 JSON。示例使用 nlohmann/json它不是唯一选择如果你的项目已有 RapidJSON 或 simdjson也可以替换。接口层向业务暴露稳定类型例如ChatRequest、ChatResponse、ChatClient。业务代码不应该直接依赖底层 HTTP 返回体。错误层把失败分成可处理的类别例如配置错误、网络错误、HTTP 错误、解析错误和模型服务错误。这样调用方才能决定是提示用户、重试、降级还是报警。如果你使用模型中转或聚合服务例如 HaerAPIhttps://www.haerapi.com也应先确认其当前文档是否提供与你代码匹配的接口路径、鉴权头、请求字段和响应格式若文档不一致应调整 SDK 的协议层而不是在业务代码里打补丁。项目结构可以从下面的目录开始llm-sdk-demo/ CMakeLists.txt include/ llm_client.h src/ llm_client.cpp main.cpp依赖建议通过系统包管理器或 vcpkg、Conan 管理。下面的 CMake 假设系统中已经能找到 libcurl并通过 FetchContent 拉取 nlohmann/json。生产项目应按公司依赖治理规则固定版本和来源。cmake_minimum_required(VERSION 3.20) project(llm_sdk_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(CURL REQUIRED) include(FetchContent) FetchContent_Declare( nlohmann_json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz ) FetchContent_MakeAvailable(nlohmann_json) add_executable(llm_sdk_demo src/main.cpp src/llm_client.cpp ) target_include_directories(llm_sdk_demo PRIVATE include) target_link_libraries(llm_sdk_demo PRIVATE CURL::libcurl nlohmann_json::nlohmann_json)定义稳定接口先写头文件。注意这里没有暴露 HTTP 状态码细节也没有把 JSON 对象泄漏给业务层。#pragmaonce#includefunctional#includeoptional#includestring#includevectorenumclassLlmErrorCode{None,ConfigError,NetworkError,HttpError,ParseError,ServiceError};structLlmError{LlmErrorCode codeLlmErrorCode::None;std::string message;inthttp_status0;};templatetypenameTstructResult{std::optionalTvalue;LlmError error;boolok()const{returnvalue.has_value();}};structChatMessage{std::string role;std::string content;};structChatRequest{std::vectorChatMessagemessages;doubletemperature0.7;};structChatResponse{std::string content;};structClientConfig{std::string base_url;std::string api_key;std::string model;longtimeout_ms30000;};classChatClient{public:explicitChatClient(ClientConfig config);ResultChatResponsecomplete(constChatRequestrequest)const;Resultvoidstream(constChatRequestrequest,conststd::functionvoid(conststd::string)on_delta)const;private:ClientConfig config_;};ClientConfigload_config_from_env();这个接口刻意保持朴素。complete适合后台总结、分类、结构化抽取stream适合命令行、聊天窗口或需要边生成边展示的场景。两者共用请求结构避免业务层为同一个模型维护两套参数。实现配置读取环境变量读取要尽早失败。缺少密钥或模型名时不要等到第一次请求才报错。#includellm_client.h#includecstdlib#includestdexceptstaticstd::stringgetenv_required(constchar*key){constchar*valuestd::getenv(key);if(valuenullptr||std::string(value).empty()){throwstd::runtime_error(std::string(missing env: )key);}returnvalue;}ClientConfigload_config_from_env(){ClientConfig config;config.base_urlgetenv_required(LLM_BASE_URL);config.api_keygetenv_required(LLM_API_KEY);config.modelgetenv_required(LLM_MODEL);if(constchar*timeoutstd::getenv(LLM_TIMEOUT_MS)){config.timeout_msstd::stol(timeout);}returnconfig;}运行前可以这样设置环境变量示例值中的密钥不要替换为真实明文写入脚本仓库exportLLM_BASE_URLhttps://example.com/v1/chat/completionsexportLLM_API_KEY$YOUR_REAL_API_KEYexportLLM_MODELyour-model-nameexportLLM_TIMEOUT_MS30000实现普通响应调用下面代码展示核心思路构造 JSON、设置鉴权头、发送 POST、解析响应。不同接口的响应字段可能不一样实际接入时应按当前文档调整parse_content。#includellm_client.h#includecurl/curl.h#includenlohmann/json.hpp#includesstream#includestdexceptusingjsonnlohmann::json;namespace{size_twrite_callback(char*ptr,size_t size,size_t nmemb,void*userdata){auto*outputstatic_caststd::string*(userdata);output-append(ptr,size*nmemb);returnsize*nmemb;}jsonbuild_payload(constClientConfigconfig,constChatRequestrequest,boolstream){json messagesjson::array();for(constautomessage:request.messages){messages.push_back({{role,message.role},{content,message.content}});}return{{model,config.model},{messages,messages},{temperature,request.temperature},{stream,stream}};}std::stringparse_content(conststd::stringbody){autoparsedjson::parse(body);returnparsed.at(choices).at(0).at(message).at(content).getstd::string();}}// namespaceChatClient::ChatClient(ClientConfig config):config_(std::move(config)){}ResultChatResponseChatClient::complete(constChatRequestrequest)const{CURL*curlcurl_easy_init();if(!curl){return{{},{LlmErrorCode::NetworkError,failed to init curl,0}};}std::string response_body;std::string payloadbuild_payload(config_,request,false).dump();structcurl_slist*headersnullptr;std::string authAuthorization: Bearer config_.api_key;headerscurl_slist_append(headers,Content-Type: application/json);headerscurl_slist_append(headers,auth.c_str());curl_easy_setopt(curl,CURLOPT_URL,config_.base_url.c_str());curl_easy_setopt(curl,CURLOPT_HTTPHEADER,headers);curl_easy_setopt(curl,CURLOPT_POSTFIELDS,payload.c_str());curl_easy_setopt(curl,CURLOPT_TIMEOUT_MS,config_.timeout_ms);curl_easy_setopt(curl,CURLOPT_WRITEFUNCTION,write_callback);curl_easy_setopt(curl,CURLOPT_WRITEDATA,response_body);CURLcode rccurl_easy_perform(curl);longstatus0;curl_easy_getinfo(curl,CURLINFO_RESPONSE_CODE,status);curl_slist_free_all(headers);curl_easy_cleanup(curl);if(rc!CURLE_OK){return{{},{LlmErrorCode::NetworkError,curl_easy_strerror(rc),static_castint(status)}};}if(status200||status300){return{{},{LlmErrorCode::HttpError,response_body,static_castint(status)}};}try{return{ChatResponse{parse_content(response_body)},{}};}catch(conststd::exceptionex){return{{},{LlmErrorCode::ParseError,ex.what(),static_castint(status)}};}}这段代码仍然是最小实现。生产环境中建议补充请求 ID、日志脱敏、重试预算、连接复用和指标上报但这些都不应该改变业务层接口。流式响应如何处理流式响应通常不是一次性 JSON而是一段段事件。常见形式是 Server-Sent Events每行以data:开头最后用特殊标记结束。不同服务的字段结构可能不同所以解析器要单独封装。下面给出简化版解析逻辑收到一行后跳过空行识别结束标记再从增量字段中取文本。字段路径必须根据实际接口文档确认。namespace{std::optionalstd::stringparse_sse_delta_line(conststd::stringline){conststd::string prefixdata:;if(line.rfind(prefix,0)!0){returnstd::nullopt;}std::string dataline.substr(prefix.size());while(!data.empty()data.front() ){data.erase(data.begin());}if(data[DONE]){returnstd::string{};}autoparsedjson::parse(data);if(!parsed.contains(choices)){returnstd::nullopt;}autodeltaparsed.at(choices).at(0).value(delta,json::object());if(!delta.contains(content)){returnstd::nullopt;}returndelta.at(content).getstd::string();}}// namespace真正接入stream时libcurl 的回调可能一次收到半行也可能一次收到多行。因此工程实现里应维护缓冲区按换行切分完整事件。不要假设一次回调等于一次模型增量。structStreamState{std::string buffer;std::functionvoid(conststd::string)on_delta;};size_tstream_callback(char*ptr,size_t size,size_t nmemb,void*userdata){auto*statestatic_castStreamState*(userdata);state-buffer.append(ptr,size*nmemb);size_t pos0;while((posstate-buffer.find(\n))!std::string::npos){std::string linestate-buffer.substr(0,pos);state-buffer.erase(0,pos1);if(!line.empty()line.back()\r){line.pop_back();}if(line.empty()){continue;}try{autodeltaparse_sse_delta_line(line);if(delta!delta-empty()){state-on_delta(*delta);}}catch(...){return0;}}returnsize*nmemb;}这里没有把异常向外抛因为 C 回调边界不适合穿透 C 异常。更完整的版本可以在StreamState中记录错误再由stream返回Resultvoid。最小运行示例业务侧代码应该足够简单只关心消息和结果。#includellm_client.h#includeiostreamintmain(){try{ChatClientclient(load_config_from_env());ChatRequest request;request.messages.push_back({system,你是一个严谨的 C 代码审查助手。});request.messages.push_back({user,用三句话解释为什么 SDK 不应泄漏 HTTP 细节。});autoresultclient.complete(request);if(!result.ok()){std::cerrerror: result.error.message, http_statusresult.error.http_statusstd::endl;return1;}std::coutresult.value-contentstd::endl;return0;}catch(conststd::exceptionex){std::cerrconfig error: ex.what()std::endl;return1;}}构建和运行cmake-S.-Bbuild cmake--buildbuild ./build/llm_sdk_demo如果返回解析错误优先打印脱敏后的原始响应结构确认字段路径是否和parse_content一致。不要为了让示例“看起来能跑”而吞掉解析错误否则后续排障会更困难。错误分层建议错误分层不是为了写更多枚举而是为了让调用方能做正确动作。配置错误通常不可重试应在进程启动或健康检查阶段暴露。网络错误可以按幂等性和业务场景有限重试。HTTP 401 或 403 多半与密钥、权限或账号状态有关不应盲目重试。HTTP 429 代表限流时应该尊重服务端返回的退避信息如果没有明确退避字段也要设置本地重试上限。解析错误往往表示协议层与服务端响应不一致应进入告警或回滚流程。日志也要跟着分层。可以记录模型名、请求耗时、HTTP 状态码、错误类型和内部请求 ID但不要记录完整密钥、用户隐私文本或未经脱敏的响应体。常见问题是否应该把供应商 SDK 直接暴露给业务层不建议。供应商 SDK 可以作为底层实现但业务层最好依赖公司自己的窄接口。这样替换模型、增加审计字段或调整错误处理时不会让业务模块跟着大面积变动。为什么不用全局单例保存客户端全局单例会让测试和配置切换变麻烦。更好的方式是在应用启动时创建ChatClient再通过依赖注入传给需要的服务。命令行小工具可以直接创建但长期服务应避免隐藏依赖。流式输出是否一定比普通输出好不一定。交互式聊天通常适合流式输出因为用户能更早看到内容。后台任务、批处理、结构化抽取更适合普通响应因为完整结果更容易校验、重试和落库。要不要在 SDK 里自动重试可以但要谨慎。网络抖动和部分 5xx 可以有限重试鉴权失败、参数错误、解析失败通常不应重试。重试策略还要考虑请求是否会产生副作用以及上游是否已经做了任务级重试。如何测试这个 SDK至少准备三类测试协议层单元测试用固定 JSON 验证解析函数传输层集成测试用本地 mock HTTP 服务返回不同状态码业务侧契约测试确认错误类型和返回字段不会被无意修改。不要依赖真实外部服务作为唯一测试方式否则测试稳定性和成本都难控制。总结C 接入大模型的重点不是把一次 HTTP 请求发出去而是把变化隔离在合适的位置。一个可维护的最小 SDK 应该有稳定接口、环境变量配置、清晰错误分层、可替换协议解析和对流式响应的独立处理。当模型服务、网关或中转接口发生变化时优先修改 SDK 的配置层和协议层而不是让业务代码直接感知外部差异。这样做会让后续扩展函数调用、多模型路由、审计日志、限流和降级策略都更容易落地。