1. 为什么想不开要用 C++ 写 AI Agent
先把结论摆在最前面:用 C++ 写 AI Agent,不是因为 C++ 时髦,恰恰相反,是因为它在某些场景下“没得选”。我最初动这个念头,是在做一个需要常驻在边缘设备上的本地智能体时——设备内存只有几百兆,CPU 是 ARM 架构,没有 Python 运行时,也没有 Node。你让我在这种环境里跑一个动辄几百兆依赖的解释型 Agent 框架,根本不现实。于是只能回到最底层,用 C++ 从零把 Agent 的骨架搭起来。
这个系列我打算完整记录一遍:一个能跑、能扩展、能接大模型、能调用工具的 AI Agent,怎么用 C++ 从 0 到 1 搭出来。第一篇不写代码,先把整体架构和阅读路线讲清楚。因为如果一上来就贴代码,你大概率会在“为什么这里要抽象一层”“为什么不用现成库”这些问题上卡住,最后抄了个四不像。
先明确一下这个 Agent 到底要干什么。它不是一个聊天玩具,而是一个具备以下能力的常驻程序:接收用户输入,把输入和上下文组装成提示词发给大模型,解析模型返回的“动作意图”,调用本地工具(读写文件、执行计算、访问数据库等),把工具结果再喂回模型,循环直到任务完成。这套“思考—行动—观察”的循环,就是 Agent 和普通聊天机器人的本质区别。
那为什么是 C++?我总结了三个真实理由。第一是部署环境受限,嵌入式、桌面客户端、游戏引擎插件、工业软件,这些地方 Python 生态很难塞进去。第二是性能与延迟敏感,Agent 的循环里涉及大量字符串拼接、JSON 解析、网络往返,C++ 能把单次循环的开销压到很低,尤其是高频调用工具的场景。第三是与现有 C++ 代码库集成,很多工业软件、CAD、游戏本身就是 C++ 写的,Agent 要“下地干活”,最自然的方式就是直接嵌进去,而不是再起一个 Python 进程做 IPC。
当然,代价也很明显。C++ 没有现成的 LangChain,没有一行代码接大模型的便利,JSON 要自己选库,HTTP 要自己选库,异步要自己设计。所以这个系列的价值就在于:把这些“别人帮你做好的事”,一件件用 C++ 重新做一遍,并且讲清楚每一步的取舍。适合谁看?有 C++ 基础、想搞明白 Agent 底层原理、或者需要在受限环境里落地智能体的人。如果你只是想快速搭个 Demo,那 Python 确实更省事,这篇你可以当架构科普看。
2. 整体架构设计:一个 Agent 到底由哪些部件组成
2.1 从“思考—行动—观察”循环拆出五大模块
不管什么语言写的 Agent,核心循环都是一样的。我用最朴素的话描述一遍:用户给一个目标,模型想一步,决定要不要调工具,调完拿到结果,再想下一步,直到它认为任务完成,输出最终答案。这个循环在 C++ 里落地,我把它拆成五个模块,每个模块职责单一,方便单独测试和替换。
第一个是输入输出层,负责和外界打交道,可以是命令行、可以是 socket、也可以是嵌进宿主程序的回调。第二个是上下文管理模块,维护对话历史、系统提示词、工具描述,还要处理上下文长度超限时的裁剪策略。第三个是模型客户端,封装对外的 HTTP 请求,负责把上下文序列化成模型能懂的格式,再把返回解析回来。第四个是工具注册与调度模块,维护一张“工具名到函数”的表,负责参数校验和实际调用。第五个是循环控制器,也就是 Agent 的大脑,决定什么时候继续循环、什么时候终止、出错怎么重试。
这五个模块里,最容易被低估的是上下文管理。很多人以为 Agent 就是把历史消息一股脑塞给模型,实际上一旦对话轮次多了,token 会爆,成本会飙,模型还会“忘事”。所以裁剪策略、摘要策略、关键信息提取,这些都得在 C++ 里自己实现。我后面会专门用一篇讲这个。
2.2 为什么采用“接口抽象 + 具体实现”的分层
C++ 写 Agent 最大的坑,是把所有逻辑写在一个巨大的 main 函数里。我第一版就是这么干的,结果换一个模型厂商,改代码改到怀疑人生。后来我改成面向接口的分层设计,核心是三个抽象基类:ILLMClient、ITool、IContextStore。循环控制器只依赖这三个接口,不依赖任何具体实现。
这么设计的好处很直接。换模型厂商?写一个新的ILLMClient实现就行,控制器一行不用动。加一个新工具?继承ITool,注册进去即可。想把上下文从内存换成 SQLite 持久化?实现一个新的IContextStore。这就是所谓的“依赖倒置”,在 C++ 里通过纯虚函数和智能指针实现,成本很低,收益极高。
有人会问,抽象会不会带来性能损失?虚函数调用确实有一次间接寻址,但在 Agent 这个场景里,真正的瓶颈是网络往返和模型推理,一次虚函数调用的开销完全可以忽略。所以这里不要过早优化,先把结构做对。
2.3 单线程还是多线程:并发模型的取舍
热词里有个“ai agent 怎么扛并发”,这问题很实在。我的建议是:Agent 的单个会话循环用单线程,多个会话之间用线程池。为什么单个会话不并行?因为 Agent 的循环本质是串行依赖的——下一步的输入依赖上一步的输出,你没法把“思考”和“行动”真正并行化,强行并行只会引入复杂的同步问题。
但多个用户、多个会话之间是可以并行的。我的做法是维护一个会话表,每个会话绑定一个独立的上下文和状态机,用一个固定大小的线程池去调度。这里要注意,模型客户端和工具注册表是无状态的,可以被多线程共享;而上下文是有状态的,必须每个会话独占。这个边界划清楚,并发就不会乱。
还有一个细节:HTTP 请求本身是阻塞的,如果线程池里每个线程都卡在网络等待上,吞吐会很低。所以我在模型客户端里用了异步 IO,或者退一步用“一个请求一个线程 + 连接池”的简单方案。具体选哪种,取决于你的 QPS 目标,后面实操篇会给出两种方案的对比。
3. 核心技术点拆解:C++ 写 Agent 绕不开的几块硬骨头
3.1 JSON 处理:选 nlohmann 还是 RapidJSON
Agent 和大模型之间几乎全靠 JSON 通信,所以 JSON 库的选择是第一个决策点。我实测下来,nlohmann/json上手最快,语法像 Python,json::parse和j.dump()两行搞定,适合快速开发和原型验证。但它的缺点是内存占用偏高,解析大响应时性能一般。
RapidJSON则是另一个极端,性能极强,内存可控,但 API 偏底层,写起来啰嗦,容易出错。我的选择是:开发阶段用 nlohmann,性能瓶颈出现后再局部替换成 RapidJSON。因为 Agent 的 JSON 报文通常不大(几 KB 到几十 KB),nlohmann 完全够用,没必要一上来就上重型武器。
这里有个坑要提醒:大模型返回的 JSON 经常不“干净”,可能带 markdown 代码块标记,可能字段缺失,可能类型不对。所以解析前一定要做容错预处理,比如剥掉 ```json 包裹、补全缺失字段、对类型做宽松转换。这个逻辑我封装成了一个safe_parse函数,后面会给完整实现。
3.2 HTTP 客户端:libcurl 是绕不过去的选择
C++ 标准库没有 HTTP,所以必须选第三方库。libcurl是最成熟、跨平台最好的选择,几乎所有系统都有预编译包。它的用法是经典的 C 风格回调,写起来不算优雅,但胜在稳定。另一个选择是cpp-httplib,纯头文件,用法简单,适合轻量场景,但它对 HTTPS 和复杂超时的支持不如 libcurl。
我的方案是:用 libcurl 做底层传输,外面包一层 C++ 的 RAII 封装,把curl_easy_init和curl_easy_cleanup放进构造函数和析构函数,避免忘记释放。同时封装一个post_json方法,输入 URL、请求头、请求体,输出响应字符串,把超时、重试、错误码处理都收进去。这样上层调用就一行,干净利落。
要注意的是,libcurl 的全局初始化curl_global_init必须在任何线程创建之前调用,而且整个进程只调一次。这个坑我踩过,多线程下没初始化就发请求,会随机崩溃,排查了很久。
3.3 工具调用的参数校验与安全边界
Agent 调用工具是最危险的地方,因为模型可能生成任意参数。比如一个“删除文件”工具,模型可能传进来一个../../etc/passwd这样的路径。所以工具层必须做参数校验和权限边界,不能信任模型。
我的做法是每个工具在注册时声明自己的参数 schema,包括类型、范围、是否必填。调用前先按 schema 校验,不通过就直接返回错误给模型,让它重新生成。对于涉及文件系统、命令执行的工具,还要额外做路径白名单和命令白名单。这不是过度设计,而是 Agent 落地的底线。
另外,工具的执行要有超时控制。模型可能让你执行一个死循环的计算,如果不设超时,整个 Agent 就卡死了。我用的是“独立线程执行 + 超时后放弃等待”的方案,虽然不能真正杀死线程,但至少能让主循环继续跑。
3.4 上下文裁剪:token 预算怎么算
上下文裁剪是 Agent 能不能长期运行的关键。我的策略是给上下文设一个 token 预算,比如 8000 token,然后按优先级填充:系统提示词永远保留,最近 N 轮对话优先保留,更早的对话做摘要压缩,工具返回的超长结果做截断。
token 的估算不需要精确,用“字符数除以 3”这种粗略公式就够了,因为目的是控制量级,不是精确计费。真正要精确的时候,再调用模型厂商的 tokenizer。裁剪的触发时机是在每次请求模型之前,而不是每次追加消息之后,这样能减少不必要的计算。
这里有个经验:摘要压缩要用模型来做,而不是简单截断。简单截断会丢失关键信息,导致模型“失忆”。我一般保留最近 5 轮完整对话,更早的用一次模型调用生成摘要,摘要控制在 200 字以内。这样既省 token,又保留了语义。
4. 阅读路线:这个系列我会怎么带你走
4.1 从环境搭建到第一个能跑的循环
阅读路线我按“能跑起来”到“能扛住”再到“能扩展”三个阶段来组织。第一阶段是环境搭建和最小可运行版本。你会看到怎么在 VS Code 里配好 C++ 环境,怎么用 CMake 管理依赖,怎么把 libcurl 和 nlohmann 拉进来。然后写一个最简单的循环:发一条消息给模型,拿到回复,打印出来。这一步不涉及工具,目的是先把“请求—响应”这条链路打通。
这个阶段最容易卡在环境上。热词里“vscode配置c/c++环境”“microsoft visual c++ redistributable”这些搜索量很高,说明很多人第一步就受阻。我会把 Windows 和 Linux 两套配置都写清楚,包括编译器选择、CMake 版本、依赖安装命令。Windows 上还要注意运行库的问题,缺vcruntime会导致程序起不来,这个我会单独提醒。
4.2 加入工具调用,让 Agent 真正“下地干活”
第二阶段是让 Agent 具备行动能力。我会实现工具注册表、参数校验、调用调度,然后接入几个实用工具:读文件、写文件、执行计算、查询时间。到这一步,你的 Agent 就能完成“帮我算一下这个目录下所有文件的总大小”这类任务了。
这个阶段的核心是循环控制逻辑:模型返回的是普通文本还是工具调用请求?怎么区分?怎么把工具结果拼回上下文?怎么判断任务结束?这些状态机的细节我会用一张状态转移表讲清楚,避免你写出死循环。
4.3 并发、持久化与工程化收尾
第三阶段是工程化。包括多会话并发、上下文持久化到 SQLite、日志系统、配置管理、错误重试。到这一步,你的 Agent 才算是一个能上生产的东西,而不是玩具。我会重点讲并发模型的设计和线程安全的边界,这是 C++ 写 Agent 最容易翻车的地方。
最后还会有一篇讲扩展:怎么接入不同的模型厂商,怎么支持流式输出,怎么把 Agent 嵌进现有的 C++ 项目。这些内容会随着系列推进逐步展开,每一篇都保证是可编译、可运行的完整代码,而不是伪代码。
5. 实操心得与常见问题速查
5.1 我踩过的几个典型坑
第一个坑是字符串编码。大模型返回的 UTF-8 文本里可能包含多字节字符,如果你用std::string::size()去算长度做截断,很可能把一个汉字截成半个,导致乱码甚至解析失败。正确做法是按 UTF-8 码点遍历,或者直接用成熟的 UTF-8 库处理。这个坑我在做上下文裁剪时踩得很惨,截断后的 JSON 直接解析报错。
第二个坑是异常安全。C++ 里网络请求、JSON 解析、文件操作都可能抛异常,如果不在合适的地方捕获,一个请求出错就可能让整个 Agent 进程挂掉。我的做法是在循环控制器的每一轮迭代外面包一层 try-catch,把异常转成错误消息喂回模型,让模型自己决定怎么处理。这样 Agent 就有了“自愈”能力。
第三个坑是依赖版本冲突。libcurl 和 OpenSSL 的版本如果对不上,会出现链接错误或者运行时崩溃。建议用包管理器(vcpkg 或 conan)统一管理依赖版本,不要手动下载编译。我早期手动编译 OpenSSL,折腾了一整天,最后发现是版本不匹配。
5.2 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 程序启动即崩溃 | 缺少运行库 | 安装对应版本的 Visual C++ Redistributable |
| 请求返回乱码 | 编码不一致 | 确认请求和响应都用 UTF-8,检查 Content-Type |
| 多线程下随机崩溃 | 全局资源未初始化 | 检查 curl_global_init 是否在创建线程前调用 |
| JSON 解析失败 | 模型返回带 markdown 包裹 | 解析前剥离代码块标记,做容错处理 |
| Agent 陷入死循环 | 终止条件判断有误 | 检查工具调用次数上限和任务完成判定逻辑 |
| 上下文超长报错 | 未做 token 裁剪 | 实现裁剪策略,设置 token 预算 |
| 工具调用参数错误 | 未做 schema 校验 | 注册工具时声明参数类型,调用前校验 |
5.3 给准备入坑的人几句实在话
如果你 C++ 基础还不牢,比如指针、引用、值传递的区别还搞不清,智能指针用得不熟,那我建议先补一补再来看这个系列。Agent 的代码里大量用到std::shared_ptr、std::unique_ptr、lambda、std::function,这些是基本功。热词里“c++ 引用 指针 和 值传递”搜索量高,说明这是很多人的薄弱点,值得花时间搞明白。
另外,不要一上来就追求“大而全”。我见过太多人想一步到位做一个支持几十种工具、多模型、分布式的 Agent,结果卡在第一个 HTTP 请求上就放弃了。正确的姿势是先跑通最小闭环,再逐步加功能。这个系列也是按这个节奏来的,每一篇只解决一个问题,稳扎稳打。
最后说一句关于选型的个人体会:C++ 写 Agent 确实比 Python 累,但累的地方恰恰是你能真正掌控的地方。当你需要把 Agent 塞进一个没有 Python 的环境,或者需要把延迟压到毫秒级,或者需要和现有 C++ 系统深度集成时,这些“累”就变成了优势。工具没有好坏,只有合不合适。