1. 为什么想不开要用 C++ 写 AI Agent
先把结论摆在最前面:用 C++ 写 AI Agent,不是因为 C++ 时髦,恰恰相反,是因为它在某些场景下“没得选”。我最初动这个念头,是在做一个需要长时间驻留、对内存和延迟都极其敏感的本地智能体项目时。当时用 Python 原型跑通了逻辑,但一上真实负载,GC 抖动、内存占用、启动时间全都不达标,最后只能把核心链路往 C++ 上迁。这一迁,就迁出了一整套架构思考。
所谓 AI Agent,说白了就是一个能感知输入、做决策、调用工具、再输出结果的循环体。它和传统程序最大的区别在于:它的“决策”部分往往依赖大模型推理,而“执行”部分又需要和文件系统、网络、本地进程、硬件打交道。Python 在这两头都很舒服,但舒服的代价是运行时开销。C++ 则相反,写起来啰嗦,但一旦跑起来,内存是你自己管的,线程是你自己调的,延迟是可预测的。
这篇文章面向的读者,是那些已经会写 C++、但对 AI Agent 整体架构还没什么概念的人;或者反过来,做过 Python Agent、想看看底层到底怎么落地的人。我会把整体架构拆开讲,把阅读路线铺出来,让你知道从哪下手、先啃哪块、哪些坑可以提前绕开。核心关键词就四个:C++、AI Agent、整体架构、阅读路线。整篇内容围绕这四个词展开,不跑偏。
需要提前说明的是,下面涉及的很多工程细节,是基于我自己的实践和业内常见做法做的合理补全,不是唯一答案。你完全可以根据自己的场景调整,但思路是通用的。
2. 整体架构到底长什么样
2.1 一个 Agent 的最小闭环
在动手写代码之前,得先在脑子里把 Agent 的骨架搭起来。我习惯把它拆成五个部分:输入层、决策层、工具层、记忆层、输出层。这五块构成一个闭环,缺一不可。
输入层负责接收外部信息,可能是命令行参数、网络请求、文件变化,也可能是定时触发。决策层是核心,它把当前上下文喂给模型,拿到模型的意图或动作指令。工具层是 Agent 的“手”,负责真正去执行动作,比如读写文件、发请求、跑命令。记忆层保存历史对话和中间状态,让 Agent 有上下文连续性。输出层把结果整理好返回给调用方。
这个闭环听起来简单,但用 C++ 实现时,每一层都有讲究。比如输入层如果用阻塞式读取,整个 Agent 就没法同时处理多个任务;决策层如果同步调用模型接口,延迟会直接卡死主线程;工具层如果不管资源生命周期,分分钟内存泄漏。所以架构设计的第一原则是:把 IO 密集和计算密集彻底分开。
我自己的做法是,主线程只负责调度和状态管理,所有可能阻塞的操作都丢到独立线程或线程池里。决策层和工具层之间通过一个任务队列通信,队列里放的是“待执行动作”的结构体。这样即使模型响应慢,工具层也不会被拖住。
2.2 为什么不用现成的框架
很多人第一反应是:不是有 LangChain、AutoGPT 这些吗,为什么还要自己用 C++ 写?这个问题我被问过无数次。答案很直接:那些框架大多是 Python 生态的,它们的抽象层次很高,方便快速验证,但一旦你要控制内存布局、要嵌入到已有 C++ 工程、要在没有 Python 运行时的环境里跑,它们就无能为力了。
还有一个更现实的原因:依赖管理。Python 项目拉一堆包,版本冲突是家常便饭。C++ 虽然也有依赖问题,但一旦编译通过,产物就是一个可执行文件或动态库,部署时不用再操心运行时环境。对于需要分发给终端用户的 Agent 产品,这一点极其重要。
当然,自己写不代表什么都从零造。C++ 生态里有成熟的 HTTP 库、JSON 库、线程库,这些直接用就行。真正需要自己设计的是 Agent 的调度逻辑和状态机,这部分才是核心竞争力。
2.3 分层设计的取舍
我在架构上做过一次比较大的重构,起因是最初把所有逻辑塞在一个类里,结果代码膨胀到三千多行,改一处崩三处。后来改成严格分层:core层只放抽象接口和数据结构,runtime层放调度和线程管理,tools层放具体工具实现,llm层封装模型调用。层与层之间只通过接口通信,不直接依赖具体实现。
这样做的代价是前期代码量翻倍,但后期维护成本直线下降。举个例子,后来我想把模型从一种接口换成另一种,只改了llm层的一个实现类,上层完全无感。如果还是单体结构,这种替换几乎等于重写。
分层还有一个好处是可测试性。core层的逻辑可以脱离网络和模型单独跑单元测试,这在调试阶段能省下大量时间。我现在的习惯是,任何核心逻辑必须先能在没有外部依赖的情况下跑通,再接入真实环境。
3. 核心模块拆解与实操要点
3.1 决策层:模型调用与提示词管理
决策层是整个 Agent 的大脑,它要做的事情是:拿到当前上下文,构造提示词,调用模型,解析返回结果,转成可执行的动作。听起来是四步,但每一步都有坑。
先说提示词管理。很多人把提示词硬编码在代码里,这是大忌。我的做法是把提示词模板放在独立文件里,运行时加载。这样做的好处是改提示词不用重新编译,而且可以做多套模板切换。模板里用占位符标记变量位置,比如{{context}}、{{tools}},加载后做字符串替换。
模型调用这块,C++ 没有官方 SDK 的情况下,通常走 HTTP 接口。这里要注意的是超时和重试。模型接口偶尔抽风是常态,如果不设超时,一个请求卡住整个 Agent 就废了。我的配置是连接超时 5 秒,读取超时 60 秒,失败重试两次,每次间隔递增。重试次数不能太多,否则用户等不起。
解析返回结果时,最稳妥的方式是让模型输出结构化格式,比如 JSON。但模型不一定听话,所以解析器必须能容错。我的做法是先尝试严格解析,失败后走正则提取,再失败就返回一个“解析失败”的动作,让 Agent 自己决定怎么处理。这种降级策略在实际运行中救过很多次场。
注意:模型返回的内容长度不可控,解析前一定要做长度检查,防止超长字符串把内存撑爆。
3.2 工具层:动作注册与执行隔离
工具层是 Agent 和外部世界交互的通道。每个工具本质上是一个函数,输入是参数,输出是结果。但直接调用函数太危险,因为模型可能给出非法参数。所以我在工具层加了一层“动作注册表”。
注册表里每个动作有名字、参数 schema、执行函数。模型返回的动作名先去注册表里查,查不到就拒绝执行。参数也要按 schema 校验,类型不对、范围不对都拒绝。这一层校验能挡掉大部分低级错误。
执行隔离是另一个重点。工具执行可能崩溃、可能死循环、可能占用大量资源。如果直接在主线程跑,一个工具出问题整个 Agent 就挂了。我的方案是把工具执行放到独立线程,主线程只等结果,超时就强制终止。C++ 里终止线程不是标准操作,所以更稳妥的做法是工具内部自己检查超时标志,主动退出。
下面是一个动作注册的简化示例,展示结构设计思路:
struct Action { std::string name; std::function<bool(const Json&)> validate; std::function<Json(const Json&)> execute; }; class ActionRegistry { public: void registerAction(const Action& action) { actions_[action.name] = action; } Json run(const std::string& name, const Json& params) { auto it = actions_.find(name); if (it == actions_.end()) { return {{"error", "unknown action"}}; } if (!it->second.validate(params)) { return {{"error", "invalid params"}}; } return it->second.execute(params); } private: std::unordered_map<std::string, Action> actions_; };这段代码的关键在于validate和execute分离。校验不通过直接返回错误,不进入执行阶段。执行阶段即使抛异常,也被上层捕获,不会影响注册表本身。
3.3 记忆层:上下文窗口与持久化
记忆层负责保存对话历史和中间状态。这里最大的约束是模型的上下文窗口有限,不可能把所有历史都塞进去。所以记忆层要做的第一件事是裁剪。
我的裁剪策略是保留最近 N 轮对话,加上一个摘要。摘要由模型自己生成,把更早的对话压缩成几句话。这样既保留了关键信息,又控制了长度。摘要的生成时机是当历史长度超过阈值时触发,不是每轮都做,否则开销太大。
持久化方面,我用的是简单的追加日志。每轮对话追加一行 JSON,需要恢复时从头读。这种方式写入快、实现简单,缺点是文件会越来越大。所以我会定期做压缩,把旧日志合并成快照。快照里只保留摘要和最近几轮,旧日志归档。
提示:记忆文件一定要加锁或做原子写入,否则多线程环境下容易写坏。
3.4 调度层:线程模型与任务队列
调度层是 C++ Agent 区别于 Python Agent 的核心。Python 有 GIL,多线程基本是摆设,只能靠多进程。C++ 没有这个限制,可以真正并行。
我的线程模型是:一个主线程负责事件循环,一个模型线程负责调用接口,一个工具线程池负责执行动作。主线程从输入层拿事件,转成任务丢进队列。模型线程从队列取任务,调用模型,把结果再丢回队列。工具线程池从队列取动作,执行后把结果丢回队列。主线程最后统一处理结果,更新状态,输出响应。
这个模型的关键是队列的线程安全。我用的是带条件变量的阻塞队列,生产者推入时通知,消费者等待时挂起。队列容量设上限,满了就阻塞生产者,防止内存无限增长。
template<typename T> class BlockingQueue { public: void push(T item) { std::unique_lock<std::mutex> lock(mutex_); notFull_.wait(lock, [this]{ return queue_.size() < capacity_; }); queue_.push(std::move(item)); notEmpty_.notify_one(); } T pop() { std::unique_lock<std::mutex> lock(mutex_); notEmpty_.wait(lock, [this]{ return !queue_.empty(); }); T item = std::move(queue_.front()); queue_.pop(); notFull_.notify_one(); return item; } private: std::queue<T> queue_; std::mutex mutex_; std::condition_variable notEmpty_; std::condition_variable notFull_; size_t capacity_ = 100; };这个队列看起来简单,但实际用的时候要注意:push和pop里持有锁的时间要尽量短,复杂操作放到锁外做。另外,程序退出时要能优雅关闭,否则线程可能永远卡在wait上。我的做法是加一个shutdown标志,关闭时先设标志再通知所有等待线程。
4. 从零到一的阅读路线怎么排
4.1 先补哪些 C++ 基础
如果你 C++ 基础还不牢,直接啃 Agent 架构会很痛苦。我的建议是先过一遍这几个主题:智能指针、移动语义、lambda 表达式、线程与互斥量、条件变量。这五块是写 Agent 的最低要求。
智能指针解决的是内存管理问题。Agent 里对象生命周期复杂,裸指针很容易泄漏或悬空。shared_ptr和unique_ptr能挡掉大部分问题。移动语义解决的是性能问题,Agent 里大量传递大对象,拷贝开销很致命。lambda 和std::function是回调的基础,工具注册、事件处理都靠它。线程和同步原语是调度层的地基,不懂这些就没法写并发。
学习顺序上,我建议先写几个小练习:用shared_ptr管理一个对象图,用std::thread跑一个生产者消费者模型,用std::function实现一个简单回调系统。这三个练习做完,再回头看 Agent 代码就顺了。
4.2 再啃哪些工程能力
基础语法过关后,下一步是工程能力。具体包括:CMake 构建、依赖管理、日志系统、配置解析、单元测试。这些不是语言特性,但决定了你的项目能不能长大。
CMake 是 C++ 项目的标配,必须会写CMakeLists.txt。依赖管理我推荐用包管理器,手动下载编译第三方库太痛苦。日志系统不要用printf或cout,用成熟库,支持分级和异步。配置解析用 JSON 或 YAML 库,别自己写解析器。单元测试用 Catch2 或 GoogleTest,核心逻辑必须有测试覆盖。
这些工程能力看起来和 Agent 无关,但实际开发中,它们占用的时间可能比核心逻辑还多。提前掌握能省下大量返工时间。
4.3 最后攻哪些 Agent 专属知识
有了 C++ 基础和工程能力,最后才是 Agent 专属知识。这部分包括:提示词工程、模型接口协议、工具设计模式、状态机设计。这些知识更新快,需要持续跟进。
提示词工程的核心是“让模型输出可解析的结果”。我的经验是,与其让模型自由发挥,不如给它明确的格式约束。比如要求输出 JSON,并给出示例。模型接口协议要熟悉 HTTP、SSE 流式返回、错误码含义。工具设计模式要理解“幂等”“超时”“重试”这些概念。状态机设计要能把 Agent 的各个状态和转换关系画清楚。
这部分我建议边做边学,不要等全学会了再动手。先跑通一个最小闭环,再逐步加功能。每加一个功能,就补一块知识,这样学得最扎实。
5. 实操中踩过的坑与排查技巧
5.1 内存与生命周期问题
C++ 写 Agent 最容易出问题的地方就是内存。我踩过最惨的一次是工具执行完返回一个引用,结果对象已经析构,上层拿到的是野指针,程序随机崩溃。排查了两天才定位到。
避免这类问题的原则是:跨线程传递的数据一律用值或智能指针,不用引用。引用只在同一作用域内短距离传递。另外,工具执行结果统一用shared_ptr包装,谁需要谁持有,生命周期由引用计数管理。
还有一个坑是循环引用。两个对象互相持有shared_ptr,谁都不会释放。解决办法是其中一方改用weak_ptr。这个在 Agent 的父子任务关系里很常见,父任务持有子任务,子任务又需要回调父任务,一不小心就循环了。
5.2 线程死锁与竞态
多线程环境下,死锁和竞态是另一个高频问题。我遇到过一次典型死锁:线程 A 持有锁 1 等锁 2,线程 B 持有锁 2 等锁 1,两边都不放。原因是加锁顺序不一致。
解决办法是统一加锁顺序。所有地方都按同一顺序获取多个锁,就不会死锁。如果做不到,就用std::scoped_lock一次性锁多个,它内部会处理顺序。竞态问题更隐蔽,通常表现为数据偶尔不对。排查方法是加日志,记录每次读写前后的值,对比找出不一致的时机。
注意:调试多线程问题时,日志本身可能改变时序,导致问题复现不了。这时候要用专门的工具,或者加内存屏障强制同步。
5.3 模型接口不稳定
模型接口不稳定是外部依赖问题,但必须处理。我遇到过接口返回空、返回截断、返回格式错误、超时等各种情况。应对策略是分层降级:先重试,重试失败换备用接口,备用也失败就返回兜底结果。
兜底结果的设计很重要。不能直接报错让 Agent 卡死,而是返回一个“暂时无法处理”的动作,让 Agent 决定是等待还是跳过。这样即使外部服务挂了,Agent 本身还能继续运行。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 程序随机崩溃 | 野指针或悬空引用 | 检查跨线程数据传递 | 改用值或智能指针 |
| 内存持续增长 | 内存泄漏或循环引用 | 用工具检测引用计数 | 打破循环,检查释放路径 |
| 线程卡死 | 死锁或条件变量误用 | 检查加锁顺序和通知逻辑 | 统一加锁顺序,补通知 |
| 模型返回解析失败 | 格式不符或截断 | 打印原始返回内容 | 加容错解析和降级 |
| 工具执行超时 | 工具内部阻塞 | 检查工具实现 | 加超时检查和强制退出 |
| 启动慢 | 初始化逻辑过重 | 分析启动阶段耗时 | 延迟初始化,异步加载 |
这张表是我自己排查问题时总结的,实际遇到的情况可能更复杂,但大方向跑不出这几类。关键是要有日志,没有日志的排查等于盲人摸象。
6. 这套架构还能怎么扩展
架构搭好之后,扩展就变得容易了。我后来加过几个功能,都是基于现有分层直接插进去的。比如加一个“计划执行”模块,让 Agent 能先规划多步再执行,只需要在决策层和工具层之间加一个计划队列,其他层不用动。又比如加一个“多 Agent 协作”模块,让多个 Agent 实例通过消息队列通信,只需要在调度层加一个消息路由,核心逻辑复用。
扩展时要注意的是不要破坏原有抽象。新功能应该通过接口接入,而不是直接改核心类。我见过太多项目,一开始分层清晰,后来为了赶进度到处打补丁,最后又变成一团乱麻。保持纪律比什么都重要。
性能优化也是扩展方向。C++ 的优势在于可以精细控制,比如把热点路径的内存取对齐、把频繁分配的对象放进对象池、把同步调用改成异步。这些优化在 Python 里很难做,在 C++ 里是常规操作。但优化要有数据支撑,先用性能分析工具找到瓶颈,再针对性优化,不要凭感觉瞎改。
最后再分享一个小技巧:写 Agent 的时候,把“决策”和“执行”的日志分开记录。决策日志记模型输入输出,执行日志记工具调用和结果。这样出问题时能快速定位是模型判断错了,还是工具执行错了。这个习惯帮我省下了大量排查时间,你也可以试试。