很多团队第一次接入大模型时,会把 HTTP 请求直接写进业务代码:控制器里拼 JSON,服务层里写鉴权头,前端需要流式输出时又临时加一套回调。短期看能跑,长期看会出现三个问题。

第一,模型供应方、接口路径、鉴权方式、超时策略可能变化。如果调用逻辑散落在业务模块里,替换一次模型就要改很多地方。第二,错误语义不清晰。网络超时、鉴权失败、限流、模型内容为空、JSON 解析失败都被包装成“调用失败”,排查成本很高。第三,流式输出和普通输出经常混在一起,导致业务层既要理解 HTTP,又要理解增量消息格式。

更稳妥的做法是先写一个小而清晰的 C++ SDK:业务层只面对统一的 ChatClient 接口;SDK 内部负责配置、请求、响应解析、错误分类和可观测日志。它不需要一开始就做成复杂框架,但必须把边界划清楚。

设计目标

这个最小 SDK 只解决四件事:

  1. 用配置描述模型接入点,而不是在代码里硬编码地址和密钥。
  2. 对业务暴露同步调用和流式调用两个入口。
  3. 把 HTTP、JSON 和供应方响应格式限制在 SDK 内部。
  4. 给调用方返回可判断的错误类型,而不是只返回字符串。

边界也要明确:本文示例不声称兼容任何特定平台的全部能力,不实现函数调用、多模态输入、重试队列或连接池。真实项目可以在这个骨架上扩展,但不应该把第一版 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 的配置层和协议层,而不是让业务代码直接感知外部差异。这样做会让后续扩展函数调用、多模型路由、审计日志、限流和降级策略都更容易落地。

更多推荐