1. 为什么会有这个系列:从一次性能焦虑说起
去年年底我接手了一个内部工具项目,需求很明确:做一个能自动抓取数据、调用模型、执行本地脚本、再把结果整理成报告的小型智能体。团队里有人提议用 Python 快速搭一版,毕竟生态成熟、上手快,三天就能跑通 demo。但我犹豫了。
犹豫的原因不是 Python 不好,而是这个工具最终要嵌入到一个已有的桌面端产品里,那个产品本身是 C++ 写的,底层依赖了大量本地计算库和硬件接口。如果智能体用 Python 写,就意味着要额外维护一套进程间通信、打包运行时、处理跨语言调用的内存边界问题。更麻烦的是,这个智能体需要频繁做字符串解析、状态机切换、并发任务调度,Python 的 GIL 在高频小任务场景下会变成瓶颈。
于是我做了一个当时看起来有点“反直觉”的决定:用 C++ 从零写一个 AI Agent 框架。这个系列就是那次实践的完整记录。
你可能会问,C++ 写 AI Agent 是不是自找麻烦?我的答案是:取决于你的场景。如果你只是做一个聊天机器人、一个 RAG 问答、一个跑在云端的轻量服务,Python 或 TypeScript 确实是更优解。但如果你的智能体需要嵌入现有 C++ 系统、需要极低的调度延迟、需要精细控制内存和线程、需要和本地硬件或高性能计算库深度交互,那么 C++ 的价值就出来了。
这个系列适合三类人:第一类是有 C++ 基础、想了解智能体架构怎么落地的开发者;第二类是做嵌入式或桌面端、需要在现有 C++ 工程里集成 AI 能力的工程师;第三类是想从底层理解 Agent 运行机制、不想只停留在调 API 层面的技术爱好者。我会从整体架构讲起,然后一步步拆解核心模块,最后给出可复现的代码骨架和踩坑记录。
2. 整体架构设计:一个 C++ Agent 应该长什么样
2.1 核心模块划分与职责边界
一个 AI Agent 不管用什么语言写,本质上都是“感知—决策—执行”的循环。但在 C++ 里,这个循环的每个环节都需要更明确的资源管理和线程模型。我把整个框架拆成了六个核心模块,每个模块的职责边界必须清晰,否则 C++ 的复杂性会迅速失控。
第一个模块是Task 抽象层。它负责定义“一个任务是什么”,包括输入参数、期望输出、超时时间、优先级、重试策略。在 C++ 里,我选择用纯虚基类ITask来定义接口,所有具体任务继承它。这样做的好处是任务可以统一进入队列,调度器不需要知道任务的具体类型。
第二个模块是Context 管理器。智能体在执行过程中需要维护对话历史、工具调用记录、中间状态。Python 里可以用字典随便塞,但 C++ 里必须明确生命周期。我用一个Context类持有std::shared_ptr管理的状态对象,所有状态变更都通过方法调用,避免裸指针到处传。
第三个模块是Tool 注册与调用层。智能体要干活,就得调用外部工具:读文件、发请求、执行命令、查数据库。每个工具都实现统一的ITool接口,注册到ToolRegistry里。调用时通过名称查找,参数用std::variant或 JSON 对象传递。这里的关键是工具调用的隔离性——一个工具崩溃不能拖垮整个 Agent。
第四个模块是LLM 交互层。这一层负责和模型服务通信,构造 prompt、发送请求、解析响应、处理流式输出。C++ 里没有像 LangChain 那样现成的库,所以需要自己封装 HTTP 客户端和 JSON 解析。我选的是libcurl加nlohmann/json,够用且稳定。
第五个模块是调度器。这是整个 Agent 的心脏。它维护一个任务队列,根据优先级和依赖关系决定下一个执行什么。调度器需要处理并发,我用的是线程池加条件变量的方案,避免每个任务都创建新线程。
第六个模块是日志与可观测性。C++ 项目最容易忽视这块,但 Agent 的行为链路很长,没有日志根本没法调试。我集成了spdlog,并且定义了统一的日志格式,包含任务 ID、模块名、耗时、状态。
这六个模块之间的关系可以用一句话概括:调度器从队列取任务,任务通过 Context 获取状态,通过 ToolRegistry 调用工具,通过 LLM 层和模型交互,所有动作都写入日志。
2.2 为什么不用现成框架:C++ 生态的现实考量
你可能会想,不是有 LangChain 吗?不是有 Spring AI 吗?为什么不用?答案很简单:这些框架的 C++ 支持要么不存在,要么只是社区维护的绑定,版本滞后、文档稀缺、出问题很难排查。
我实际调研过几个方案。LangChain 官方没有 C++ 版本,社区有一些非官方移植,但更新频率很低,而且大量依赖 Python 运行时。Spring AI 是 Java 生态的,和 C++ 集成需要走 JNI,复杂度不比从零写低。还有一些所谓的“C++ AI 框架”,点进去一看,要么是只做了 HTTP 封装,要么是几年前停更的玩具项目。
更关键的是,智能体的核心逻辑其实并不复杂。它不像深度学习训练那样需要庞大的算子库,也不像推理引擎那样需要极致的性能优化。它本质上是一个任务编排系统,加上模型调用和工具调用。这部分逻辑用 C++ 写,代码量可控,而且能完全掌控行为。
当然,自己写意味着要处理很多琐碎的事情:JSON 解析、HTTP 超时、线程安全、错误传播。但这些恰恰是 C++ 工程师的日常,不算不可逾越的障碍。
2.3 阅读路线图:从骨架到血肉的推进顺序
这个系列我不会一上来就贴几千行代码,那样没人看得下去。我的安排是沿着“先跑通、再优化、后扩展”的路线推进。
第一阶段是最小可运行骨架。我会先定义一个最简单的 Agent,它只能做一件事:接收用户输入,调用一次模型,返回结果。这个阶段不涉及工具调用、不涉及并发、不涉及复杂状态管理。目的是让你先看到 C++ Agent 的基本形态。
第二阶段是工具调用与状态管理。在骨架基础上加入 ToolRegistry 和 Context,让 Agent 能调用外部函数,并记住调用历史。这个阶段会涉及std::variant、std::function、智能指针的使用技巧。
第三阶段是并发与调度。引入线程池和任务队列,让 Agent 能同时处理多个请求。这里会重点讲 C++ 并发编程在 Agent 场景下的特殊考量,比如如何避免死锁、如何做超时控制、如何保证线程安全。
第四阶段是可观测性与错误处理。加入结构化日志、指标采集、错误重试机制。这部分是生产级 Agent 和 demo 级 Agent 的分水岭。
第五阶段是扩展与集成。讲如何把 Agent 嵌入现有 C++ 工程,如何和 Python 工具混合使用,如何做性能调优。
每个阶段都有可运行的代码,你可以跟着一步步搭起来。我建议不要跳阶段,因为后面的内容依赖前面的基础。
3. 核心细节解析:C++ 写 Agent 的关键技术点
3.1 任务抽象与生命周期管理
在 C++ 里设计任务接口,第一个要解决的问题是:任务的状态怎么表示?我用的是一个枚举加状态机的方案。
enum class TaskState { Pending, Running, Completed, Failed, Cancelled };每个任务对象内部维护自己的状态,状态转换只能通过特定方法触发。比如markRunning()只能在Pending状态下调用,否则抛异常。这样做的好处是,当 Agent 并发执行时,任何非法状态转换都会立刻暴露,而不是悄悄产生错误结果。
任务的输入输出我用std::any来承载,虽然std::any有性能开销,但在任务粒度上可以接受。如果你追求极致性能,可以用模板化的任务类,但那样会牺牲运行时的灵活性。我的选择是先用std::any保证通用性,后续再针对热点路径做特化。
生命周期管理是 C++ Agent 最容易出问题的地方。一个任务从创建到销毁,可能经历队列等待、执行、回调、清理等多个阶段。我用std::shared_ptr来管理任务对象,确保只要还有任何模块持有引用,对象就不会被销毁。但这也带来循环引用的风险,所以 Context 里持有任务引用时用的是std::weak_ptr。
注意:在任务回调里不要长时间持有
shared_ptr,否则会导致任务对象无法及时释放,在高频场景下内存会持续增长。
3.2 工具注册机制与类型安全调用
工具调用的核心矛盾是:Agent 需要动态调用未知的工具,但 C++ 是静态类型语言。我的解决方案是两层设计。
第一层是工具注册。每个工具在注册时,需要提供工具名、描述、参数 schema、以及一个统一的调用函数。调用函数的签名是固定的:
using ToolFunction = std::function<ToolResult(const nlohmann::json& params)>;所有工具都把参数包装成 JSON,返回值也包装成 JSON。这样注册表里存的就是统一的函数类型,不需要模板。
第二层是类型安全的参数解析。工具内部从 JSON 里取参数时,用辅助函数做类型检查和转换:
template<typename T> T getParam(const nlohmann::json& params, const std::string& key) { if (!params.contains(key)) { throw ToolException("Missing parameter: " + key); } return params.at(key).get<T>(); }这样如果模型传错了参数类型,会在工具调用时立刻抛出明确异常,而不是产生未定义行为。
工具注册表本身用std::unordered_map<std::string, ToolEntry>存储,读写用std::shared_mutex保护。因为工具注册通常在启动时完成,运行时主要是读操作,读写锁比互斥锁更合适。
3.3 模型交互层的超时与重试设计
和模型服务通信是 Agent 里最不可控的环节。网络抖动、服务限流、响应超时都是常态。C++ 里做 HTTP 请求,我用的是libcurl的多接口模式,而不是简单接口。原因很简单:简单接口是阻塞的,一个请求卡住会拖死整个线程。
多接口模式允许在单线程里管理多个并发请求,配合curl_multi_poll做事件循环。但这样写代码复杂度高,所以我封装了一个HttpClient类,对外暴露同步接口,内部用多接口加条件变量实现。
超时控制分三层:连接超时、传输超时、总超时。连接超时设 5 秒,传输超时设 30 秒,总超时设 60 秒。超过总超时直接取消请求,返回超时错误。
重试策略我采用的是指数退避加抖动。第一次失败后等 1 秒,第二次等 2 秒,第三次等 4 秒,每次加随机抖动避免惊群。最多重试三次,三次都失败就标记任务失败。
实操心得:重试只对可恢复错误生效,比如超时、连接失败、5xx 响应。对于 4xx 响应,重试没有意义,直接失败更快。
3.4 并发模型选择:线程池还是协程
C++ 里做并发,传统方案是线程池,新方案是 C++20 协程。我两个都试过,最终选择了线程池加任务队列的方案。
协程的优势是轻量、上下文切换开销小,适合大量 IO 密集型任务。但协程的生态还不成熟,调试工具少,异常处理复杂,而且和现有 C++ 库的集成需要大量适配代码。对于一个需要快速落地、稳定运行的项目来说,线程池是更稳妥的选择。
线程池的核心参数是线程数量。我一开始设的是 CPU 核心数,但实测发现 Agent 的任务大部分时间在等 IO,CPU 利用率很低。后来改成 CPU 核心数的两倍,吞吐量明显提升。再往上加,收益递减,而且线程切换开销开始显现。
任务队列我用的是std::deque加std::mutex加std::condition_variable。没有用无锁队列,因为 Agent 的任务频率还没到需要无锁的程度,而且无锁队列的正确性很难保证。
4. 实操过程:从零搭建最小可运行 Agent
4.1 环境准备与依赖选型
先说环境。我用的编译器是 GCC 11,C++ 标准开到 C++20。构建系统用的是 CMake 3.20 以上。依赖库有四个:libcurl做 HTTP,nlohmann/json做 JSON 解析,spdlog做日志,fmt做字符串格式化。这四个库都是 header-only 或者容易静态链接的,部署时不需要额外装运行时。
CMake 配置的关键是找到这些依赖。libcurl用find_package(CURL REQUIRED),nlohmann/json和spdlog可以直接用FetchContent拉取,避免手动下载。
cmake_minimum_required(VERSION 3.20) project(cpp_agent CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(CURL REQUIRED) include(FetchContent) FetchContent_Declare( json GIT_REPOSITORY https://github.com/nlohmann/json.git GIT_TAG v3.11.3 ) FetchContent_MakeAvailable(json) FetchContent_Declare( spdlog GIT_REPOSITORY https://github.com/gabime/spdlog.git GIT_TAG v1.13.0 ) FetchContent_MakeAvailable(spdlog) add_executable(agent main.cpp) target_link_libraries(agent PRIVATE CURL::libcurl nlohmann_json::nlohmann_json spdlog::spdlog)注意:如果你在 Windows 上开发,
libcurl的链接可能会遇到CURL_STATICLIB的问题。解决办法是在 CMake 里加add_definitions(-DCURL_STATICLIB),并且确保链接了ws2_32和crypt32。
4.2 定义 Agent 主循环与任务结构
最小 Agent 的主循环其实很简单:读输入、构造任务、执行任务、输出结果。但为了后续扩展,我把这个循环抽象成了Agent类。
class Agent { public: Agent(std::shared_ptr<LLMClient> llm) : llm_(std::move(llm)) {} std::string run(const std::string& userInput) { auto task = std::make_shared<SimpleTask>(userInput); task->markRunning(); try { auto response = llm_->chat(task->input()); task->markCompleted(response); return response; } catch (const std::exception& e) { task->markFailed(e.what()); return "Error: " + std::string(e.what()); } } private: std::shared_ptr<LLMClient> llm_; };SimpleTask继承自ITask,只做最基本的状态管理。LLMClient封装了 HTTP 请求和 JSON 构造。
这个版本跑起来后,你可以用一句简单的输入测试:
int main() { auto llm = std::make_shared<LLMClient>("http://localhost:8080/v1/chat"); Agent agent(llm); std::cout << agent.run("你好,介绍一下你自己") << std::endl; return 0; }如果模型服务正常,你会看到返回的文本。这就是最小可运行 Agent。
4.3 加入工具调用:让 Agent 真正干活
最小版本只能聊天,不能干活。要干活就得加工具。我在Agent里加了一个ToolRegistry,并且在主循环里解析模型返回的工具调用请求。
模型返回的格式我约定为 JSON,包含tool和params两个字段。Agent 解析后查找工具、执行、把结果再喂回模型。
std::string Agent::runWithTools(const std::string& userInput) { auto task = std::make_shared<SimpleTask>(userInput); task->markRunning(); std::string currentInput = userInput; for (int round = 0; round < maxRounds_; ++round) { auto response = llm_->chat(currentInput); auto toolCall = parseToolCall(response); if (!toolCall.has_value()) { task->markCompleted(response); return response; } auto result = toolRegistry_.invoke(toolCall->name, toolCall->params); currentInput = "工具 " + toolCall->name + " 返回: " + result.dump(); } task->markFailed("Max rounds exceeded"); return "Error: too many tool calls"; }这里的关键是maxRounds_限制。没有这个限制,模型可能会陷入无限调用工具的循环。我设的是 5 轮,超过就强制终止。
工具的实现示例:
class ReadFileTool : public ITool { public: std::string name() const override { return "read_file"; } ToolResult invoke(const nlohmann::json& params) override { auto path = getParam<std::string>(params, "path"); std::ifstream file(path); if (!file.is_open()) { throw ToolException("Cannot open file: " + path); } std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>()); return ToolResult{{"content", content}}; } };注册工具:
toolRegistry_.registerTool(std::make_shared<ReadFileTool>());这样 Agent 就能读文件了。同理可以加写文件、执行命令、发 HTTP 请求等工具。
4.4 并发改造:从单线程到线程池
单线程版本跑通后,下一步是并发。我加了一个ThreadPool类,Agent 的run方法把任务提交到线程池,返回std::future。
class ThreadPool { public: explicit ThreadPool(size_t threads) { for (size_t i = 0; i < threads; ++i) { workers_.emplace_back([this] { while (true) { std::function<void()> task; { std::unique_lock lock(mutex_); cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); }); if (stop_ && tasks_.empty()) return; task = std::move(tasks_.front()); tasks_.pop(); } task(); } }); } } template<typename F> auto submit(F&& f) -> std::future<decltype(f())> { using ReturnType = decltype(f()); auto task = std::make_shared<std::packaged_task<ReturnType()>>(std::forward<F>(f)); auto future = task->get_future(); { std::unique_lock lock(mutex_); tasks_.emplace([task] { (*task)(); }); } cv_.notify_one(); return future; } ~ThreadPool() { { std::unique_lock lock(mutex_); stop_ = true; } cv_.notify_all(); for (auto& worker : workers_) { worker.join(); } } private: std::vector<std::thread> workers_; std::queue<std::function<void()>> tasks_; std::mutex mutex_; std::condition_variable cv_; bool stop_ = false; };Agent 的调用变成:
auto future = pool_.submit([this, input] { return runWithTools(input); }); auto result = future.get();实操心得:线程池的析构顺序很重要。必须先设置
stop_并通知所有线程,再 join。如果先 join 再设置stop_,线程会一直等条件变量,导致死锁。
5. 常见问题与排查技巧实录
5.1 编译链接阶段的典型坑
C++ 项目最烦人的就是编译链接问题。我整理了几个高频错误和解决办法。
第一个是undefined reference to curl_global_init。这是因为链接顺序不对,libcurl必须放在依赖它的目标文件后面。在 CMake 里用target_link_libraries通常没问题,但如果你手动写 Makefile,要注意顺序。
第二个是nlohmann/json的版本冲突。如果你项目里其他库也依赖 JSON,可能会拉入不同版本。解决办法是用FetchContent统一管理,或者用find_package指定版本。
第三个是 Windows 上的LNK2019错误,通常是运行时库不匹配。Debug 和 Release 的libcurl不能混用,必须保持一致。
| 错误信息 | 原因 | 解决办法 |
|---|---|---|
| undefined reference to curl_global_init | 链接顺序错误 | 把 CURL::libcurl 放在最后 |
| LNK2019 unresolved external | 运行时库不匹配 | 统一 Debug/Release |
| fatal error: nlohmann/json.hpp not found | 头文件路径未包含 | 检查 FetchContent 或 include_directories |
| multiple definition of json | 头文件重复包含 | 使用 include guard 或 inline |
5.2 运行时崩溃与内存问题排查
C++ Agent 运行时崩溃,最常见的原因是悬空指针和线程竞争。我遇到过几次,排查过程记录如下。
第一次崩溃是在工具调用返回后。工具返回了一个指向局部变量的引用,Agent 拿到后继续使用,结果访问了已释放的内存。解决办法是工具返回值必须是值类型或者shared_ptr,不能返回引用。
第二次崩溃是在并发场景下。两个线程同时修改 Context 里的同一个字段,导致数据竞争。用ThreadSanitizer跑了一遍,定位到问题。解决办法是 Context 的所有修改方法都加锁,或者用原子类型。
第三次崩溃是日志库的初始化顺序问题。spdlog的全局 logger 在静态初始化阶段被使用,但此时 logger 还没创建。解决办法是把 logger 的创建放到main函数开头,或者用函数内静态变量延迟初始化。
注意:C++ Agent 调试一定要开
-fsanitize=address,undefined,能提前发现大量内存问题。生产环境可以关掉,但开发阶段必须开。
5.3 模型交互的异常处理清单
和模型服务交互,异常情况比想象中多。我整理了一个速查表。
| 异常现象 | 可能原因 | 处理策略 |
|---|---|---|
| 连接超时 | 服务未启动或网络不通 | 重试三次,失败则报错 |
| 响应 429 | 请求频率超限 | 退避重试,降低并发 |
| 响应 500 | 服务内部错误 | 重试,最多三次 |
| 响应格式错误 | 模型返回非 JSON | 记录原始响应,尝试修复 |
| 流式输出中断 | 网络抖动 | 重新发起请求,或返回部分结果 |
| 工具调用参数错误 | 模型理解偏差 | 返回错误信息给模型,让其修正 |
这里特别说一下流式输出中断。Agent 如果依赖流式结果做后续决策,中断会导致状态不一致。我的做法是:流式输出只用于展示,不用于决策。决策依赖完整的最终响应。
5.4 性能调优的实测数据
最后分享一些性能调优的实测数据。测试场景是 100 个并发任务,每个任务调用一次模型加一次工具。
| 配置 | 平均延迟 | 吞吐量 | CPU 利用率 |
|---|---|---|---|
| 单线程 | 1200ms | 83 req/s | 15% |
| 4 线程 | 350ms | 285 req/s | 45% |
| 8 线程 | 220ms | 454 req/s | 70% |
| 16 线程 | 210ms | 476 req/s | 85% |
| 32 线程 | 230ms | 434 req/s | 90% |
从数据看,8 线程是性价比最高的点。再往上加,吞吐量增长放缓,延迟反而因为线程切换而上升。这个结论和机器配置有关,你的环境可能需要重新测。
另外,JSON 解析是热点。nlohmann/json虽然方便,但解析大 JSON 时性能一般。如果 Agent 处理的数据量大,可以考虑换成simdjson或者rapidjson。我实测下来,simdjson在解析 1MB 以上 JSON 时比nlohmann/json快 3 到 5 倍。
我个人在实际操作中的体会是,C++ 写 Agent 最大的挑战不是语言本身,而是工程化。你需要自己处理 Python 里框架帮你搞定的事情:序列化、并发、错误传播、资源管理。但一旦搭起来,你对整个系统的掌控力是 Python 方案给不了的。后续这个系列还会继续更新,下一篇我会讲怎么把 Agent 嵌入现有 C++ 工程,以及怎么和 Python 工具做混合调用。如果你在跟着做,建议先把最小骨架跑通,再逐步加功能,不要一上来就追求完整架构。