用 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 错误、解析错误和模型服务错误。这样调用方才能决定是提示用户、重试、降级还是报警。
如果你使用模型中转或聚合服务,例如 HaerAPI(https://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 对象泄漏给业务层。
#pragma once
#include <functional>
#include <optional>
#include <string>
#include <vector>
enum class LlmErrorCode {
None,
ConfigError,
NetworkError,
HttpError,
ParseError,
ServiceError
};
struct LlmError {
LlmErrorCode code = LlmErrorCode::None;
std::string message;
int http_status = 0;
};
template <typename T>
struct Result {
std::optional<T> value;
LlmError error;
bool ok() const { return value.has_value(); }
};
struct ChatMessage {
std::string role;
std::string content;
};
struct ChatRequest {
std::vector<ChatMessage> messages;
double temperature = 0.7;
};
struct ChatResponse {
std::string content;
};
struct ClientConfig {
std::string base_url;
std::string api_key;
std::string model;
long timeout_ms = 30000;
};
class ChatClient {
public:
explicit ChatClient(ClientConfig config);
Result<ChatResponse> complete(const ChatRequest& request) const;
Result<void> stream(const ChatRequest& request,
const std::function<void(const std::string&)>& on_delta) const;
private:
ClientConfig config_;
};
ClientConfig load_config_from_env();
这个接口刻意保持朴素。complete 适合后台总结、分类、结构化抽取;stream 适合命令行、聊天窗口或需要边生成边展示的场景。两者共用请求结构,避免业务层为同一个模型维护两套参数。
实现配置读取
环境变量读取要尽早失败。缺少密钥或模型名时,不要等到第一次请求才报错。
#include "llm_client.h"
#include <cstdlib>
#include <stdexcept>
static std::string getenv_required(const char* key) {
const char* value = std::getenv(key);
if (value == nullptr || std::string(value).empty()) {
throw std::runtime_error(std::string("missing env: ") + key);
}
return value;
}
ClientConfig load_config_from_env() {
ClientConfig config;
config.base_url = getenv_required("LLM_BASE_URL");
config.api_key = getenv_required("LLM_API_KEY");
config.model = getenv_required("LLM_MODEL");
if (const char* timeout = std::getenv("LLM_TIMEOUT_MS")) {
config.timeout_ms = std::stol(timeout);
}
return config;
}
运行前可以这样设置环境变量,示例值中的密钥不要替换为真实明文写入脚本仓库:
export LLM_BASE_URL="https://example.com/v1/chat/completions"
export LLM_API_KEY="$YOUR_REAL_API_KEY"
export LLM_MODEL="your-model-name"
export LLM_TIMEOUT_MS="30000"
实现普通响应调用
下面代码展示核心思路:构造 JSON、设置鉴权头、发送 POST、解析响应。不同接口的响应字段可能不一样,实际接入时应按当前文档调整 parse_content。
#include "llm_client.h"
#include <curl/curl.h>
#include <nlohmann/json.hpp>
#include <sstream>
#include <stdexcept>
using json = nlohmann::json;
namespace {
size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {
auto* output = static_cast<std::string*>(userdata);
output->append(ptr, size * nmemb);
return size * nmemb;
}
json build_payload(const ClientConfig& config, const ChatRequest& request, bool stream) {
json messages = json::array();
for (const auto& message : request.messages) {
messages.push_back({{"role", message.role}, {"content", message.content}});
}
return {
{"model", config.model},
{"messages", messages},
{"temperature", request.temperature},
{"stream", stream}
};
}
std::string parse_content(const std::string& body) {
auto parsed = json::parse(body);
return parsed.at("choices").at(0).at("message").at("content").get<std::string>();
}
} // namespace
ChatClient::ChatClient(ClientConfig config) : config_(std::move(config)) {}
Result<ChatResponse> ChatClient::complete(const ChatRequest& request) const {
CURL* curl = curl_easy_init();
if (!curl) {
return {{}, {LlmErrorCode::NetworkError, "failed to init curl", 0}};
}
std::string response_body;
std::string payload = build_payload(config_, request, false).dump();
struct curl_slist* headers = nullptr;
std::string auth = "Authorization: Bearer " + config_.api_key;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_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 rc = curl_easy_perform(curl);
long status = 0;
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_cast<int>(status)}};
}
if (status < 200 || status >= 300) {
return {{}, {LlmErrorCode::HttpError, response_body, static_cast<int>(status)}};
}
try {
return {ChatResponse{parse_content(response_body)}, {}};
} catch (const std::exception& ex) {
return {{}, {LlmErrorCode::ParseError, ex.what(), static_cast<int>(status)}};
}
}
这段代码仍然是最小实现。生产环境中建议补充请求 ID、日志脱敏、重试预算、连接复用和指标上报,但这些都不应该改变业务层接口。
流式响应如何处理
流式响应通常不是一次性 JSON,而是一段段事件。常见形式是 Server-Sent Events:每行以 data: 开头,最后用特殊标记结束。不同服务的字段结构可能不同,所以解析器要单独封装。
下面给出简化版解析逻辑:收到一行后,跳过空行,识别结束标记,再从增量字段中取文本。字段路径必须根据实际接口文档确认。
namespace {
std::optional<std::string> parse_sse_delta_line(const std::string& line) {
const std::string prefix = "data:";
if (line.rfind(prefix, 0) != 0) {
return std::nullopt;
}
std::string data = line.substr(prefix.size());
while (!data.empty() && data.front() == ' ') {
data.erase(data.begin());
}
if (data == "[DONE]") {
return std::string{};
}
auto parsed = json::parse(data);
if (!parsed.contains("choices")) {
return std::nullopt;
}
auto delta = parsed.at("choices").at(0).value("delta", json::object());
if (!delta.contains("content")) {
return std::nullopt;
}
return delta.at("content").get<std::string>();
}
} // namespace
真正接入 stream 时,libcurl 的回调可能一次收到半行,也可能一次收到多行。因此工程实现里应维护缓冲区,按换行切分完整事件。不要假设一次回调等于一次模型增量。
struct StreamState {
std::string buffer;
std::function<void(const std::string&)> on_delta;
};
size_t stream_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {
auto* state = static_cast<StreamState*>(userdata);
state->buffer.append(ptr, size * nmemb);
size_t pos = 0;
while ((pos = state->buffer.find('\n')) != std::string::npos) {
std::string line = state->buffer.substr(0, pos);
state->buffer.erase(0, pos + 1);
if (!line.empty() && line.back() == '\r') {
line.pop_back();
}
if (line.empty()) {
continue;
}
try {
auto delta = parse_sse_delta_line(line);
if (delta && !delta->empty()) {
state->on_delta(*delta);
}
} catch (...) {
return 0;
}
}
return size * nmemb;
}
这里没有把异常向外抛,因为 C 回调边界不适合穿透 C++ 异常。更完整的版本可以在 StreamState 中记录错误,再由 stream 返回 Result<void>。
最小运行示例
业务侧代码应该足够简单,只关心消息和结果。
#include "llm_client.h"
#include <iostream>
int main() {
try {
ChatClient client(load_config_from_env());
ChatRequest request;
request.messages.push_back({"system", "你是一个严谨的 C++ 代码审查助手。"});
request.messages.push_back({"user", "用三句话解释为什么 SDK 不应泄漏 HTTP 细节。"});
auto result = client.complete(request);
if (!result.ok()) {
std::cerr << "error: " << result.error.message
<< ", http_status=" << result.error.http_status << std::endl;
return 1;
}
std::cout << result.value->content << std::endl;
return 0;
} catch (const std::exception& ex) {
std::cerr << "config error: " << ex.what() << std::endl;
return 1;
}
}
构建和运行:
cmake -S . -B build
cmake --build build
./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 的配置层和协议层,而不是让业务代码直接感知外部差异。这样做会让后续扩展函数调用、多模型路由、审计日志、限流和降级策略都更容易落地。
更多推荐

所有评论(0)