news 2026/11/2 22:42:48

C++从零实现AI Agent框架:架构设计与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++从零实现AI Agent框架:架构设计与工程实践

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 利用率
单线程1200ms83 req/s15%
4 线程350ms285 req/s45%
8 线程220ms454 req/s70%
16 线程210ms476 req/s85%
32 线程230ms434 req/s90%

从数据看,8 线程是性价比最高的点。再往上加,吞吐量增长放缓,延迟反而因为线程切换而上升。这个结论和机器配置有关,你的环境可能需要重新测。

另外,JSON 解析是热点。nlohmann/json虽然方便,但解析大 JSON 时性能一般。如果 Agent 处理的数据量大,可以考虑换成simdjson或者rapidjson。我实测下来,simdjson在解析 1MB 以上 JSON 时比nlohmann/json快 3 到 5 倍。

我个人在实际操作中的体会是,C++ 写 Agent 最大的挑战不是语言本身,而是工程化。你需要自己处理 Python 里框架帮你搞定的事情:序列化、并发、错误传播、资源管理。但一旦搭起来,你对整个系统的掌控力是 Python 方案给不了的。后续这个系列还会继续更新,下一篇我会讲怎么把 Agent 嵌入现有 C++ 工程,以及怎么和 Python 工具做混合调用。如果你在跟着做,建议先把最小骨架跑通,再逐步加功能,不要一上来就追求完整架构。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 22:42:43

Agent判断器选型指南:Laya与Jev部署对比与分级架构实践

1. 为什么要在 Agent 里塞一个“判断器”做 Agent 开发的人迟早会撞上同一堵墙&#xff1a;模型能说会道&#xff0c;但一到“该不该动手、动哪只手、动完算不算数”这种决策环节就开始飘。你给它一个任务&#xff0c;它要么过度自信地乱调工具&#xff0c;要么畏首畏尾地反复确…

作者头像 李华
网站建设 2026/11/2 22:36:20

M3D-NCA:内置质量控制的多尺度3D医学图像分割模型

3D医学图像分割这个方向&#xff0c;过去几年里模型结构的花样翻新已经卷到了一种边际收益极低的状态。nnU-Net 把训练策略和预处理做到极致之后&#xff0c;大家发现真正卡脖子的往往不是网络本身&#xff0c;而是推理结果到底可不可信——一个分割掩膜丢给下游做放疗计划或者…

作者头像 李华
网站建设 2026/11/2 22:35:57

vLLM/SGLang/Ollama/llama.cpp怎么选?机制与部署实战

开头我先说个判断&#xff1a;最近后台留言被问得最多的几个问题——"我想跑DeepSeek用哪个引擎"、"Ollama下载模型慢怎么破"、"llama.cpp能不能在手机上跑"——指向的其实是同一个困惑&#xff1a;开源推理引擎这么多&#xff0c;到底该怎么选。…

作者头像 李华
网站建设 2026/11/2 22:33:16

大模型系统性入门:从原理、提示词工程到微调与部署的完整路径

写给想做“大模型”的人&#xff1a;一份真正能照着走的系统性入门资料这两年我经常在后台收到一类问题&#xff1a;“想学大模型&#xff0c;我应该买什么课&#xff1f;看哪本书&#xff1f;能不能直接甩我一份资料清单&#xff1f;”说实话&#xff0c;每次看到这种问法我都…

作者头像 李华
网站建设 2026/11/2 22:32:18

数控编程入门:G代码与M代码指令手册使用指南

简介&#xff1a;这份TOSNUC888指令手册是东芝机械TOSNUC 888数控系统G代码、M代码部分的中文翻译资料&#xff0c;面向从事数控编程、机床操作与调试的技术人员及学习者&#xff0c;可用于查阅指令功能、理解代码含义并指导实际编程应用。压缩包内共1个doc文档&#xff0c;约2…

作者头像 李华
网站建设 2026/11/2 22:31:04

系统仿真入门:从VHDL测试基准到FPGA设计验证

简介&#xff1a;这份PPT课件围绕系统仿真展开&#xff0c;面向电子设计与EDA学习者&#xff0c;尤其是正在接触VHDL与FPGA/CPLD开发流程的初学者和授课教师&#xff0c;帮助其在硬件制造前借助计算机模型验证电路逻辑、理解仿真在电子设计中的定位。压缩包内仅含1个PPT文件&am…

作者头像 李华