1. 为什么不用 Python 一把梭:从一次线上事故说起
去年冬天,我负责的一个内部知识库助手在凌晨两点崩了。原因说出来有点丢人:Python 的 GIL 加上一个没控制好的异步任务队列,导致内存曲线像坐了火箭,容器被 OOM Killer 直接干掉。第二天复盘的时候,团队里有人问了一句:“这东西要是用 C++ 写,是不是就不会这么狼狈?”当时我没接话,但这个问题在心里扎了根。
后来我真的用 C++ 从零搭了一个小型的 AI Agent 框架,跑在边缘设备上,负责本地文档检索、工具调用和简单的任务编排。整个过程踩了不少坑,也积累了一些在 Python 生态里根本不会遇到的思考。这个系列就是把这些东西整理出来,从整体架构讲到具体模块的实现,再到怎么阅读这套代码。如果你正在考虑用 C++ 做 AI Agent,或者单纯好奇“C++ 写 Agent 到底图什么”,那接下来的内容应该能帮你省下不少试错时间。
先说清楚一件事:C++ 不是用来替代 Python 做 AI Agent 的,它解决的是另一类问题。Python 在原型验证、模型训练、快速迭代上依然是王者,LangChain、LlamaIndex 这些框架把开发门槛降到了地板。但当你需要把 Agent 塞进一个内存只有 256MB 的嵌入式盒子、要求端到端延迟稳定在 50ms 以内、或者需要和已有的 C++ 推理引擎(比如 TensorRT、ONNX Runtime 的 C++ API)无缝对接时,Python 的那层解释器开销和运行时不确定性就会变成硬伤。
我见过太多团队在 Python 里把 Agent 跑通之后,到了部署阶段才发现性能不达标,然后被迫用 C++ 重写核心链路。与其事后返工,不如一开始就想清楚:你的 Agent 到底跑在什么环境里,对延迟、内存、并发的要求是什么。这个判断决定了你该用 Python 快速验证,还是直接用 C++ 做工程化落地。
提示:如果你的 Agent 只是跑在服务器上、QPS 不高、延迟要求宽松,Python 完全够用,没必要为了“性能”两个字硬上 C++。C++ 的代价是开发效率、调试成本和生态成熟度,这些账要算清楚。
2. 一个 C++ AI Agent 到底由哪些部分组成
2.1 从“输入一句话”到“输出一个动作”的完整链路
用户输入一句“帮我查一下上个月的销售数据,生成一份简报”,Agent 内部要经历什么?拆开来看,大概是这么一条链路:
- 输入解析与预处理:把原始文本做分词、编码,转成模型能吃的 token 序列。这一步在 C++ 里通常用 SentencePiece 或者 HuggingFace tokenizers 的 C++ 绑定来完成。
- 意图理解与规划:调用 LLM 做推理,判断用户意图,决定是否需要拆解成多个子任务。比如“查数据”和“生成简报”是两个步骤,可能需要先调数据库查询工具,再调文档生成工具。
- 工具调用与执行:根据规划结果,调用对应的工具函数。这些工具可能是本地函数(比如读文件、算数学),也可能是外部 API(比如查数据库、发邮件)。
- 结果聚合与回复生成:把工具返回的结果拼回上下文,再让 LLM 生成最终的自然语言回复。
- 状态管理与记忆:维护对话历史、任务状态、中间结果,支持多轮交互和断点续跑。
这条链路里,LLM 推理本身通常不是 C++ 写的,而是通过 C++ 调用已经优化好的推理引擎。C++ 的价值在于把推理之外的所有环节——调度、编排、工具执行、状态管理——用一套低开销、高确定性的方式串起来。
2.2 核心模块划分与职责边界
我最终落地的架构大概分成五层,每层的职责和边界都很清楚:
| 层级 | 模块 | 核心职责 | 典型实现 |
|---|---|---|---|
| 接口层 | Agent Interface | 对外暴露统一的请求/响应接口,处理序列化与协议适配 | gRPC / REST / 本地函数调用 |
| 编排层 | Orchestrator | 任务规划、步骤调度、状态机管理 | 自研状态机 + 任务队列 |
| 推理层 | Inference Engine | 封装 LLM 推理,管理上下文窗口和 token 预算 | ONNX Runtime / TensorRT / llama.cpp |
| 工具层 | Tool Registry | 注册、发现、调用各类工具函数 | 函数注册表 + 参数校验 |
| 基础层 | Runtime Core | 内存管理、日志、配置、线程池、错误处理 | 自研基础设施 |
这个划分的核心逻辑是关注点分离:编排层不关心推理引擎是哪个,工具层不关心任务是怎么被调度的,接口层不关心内部状态怎么维护。每一层都可以独立替换和测试。
2.3 为什么把“编排”和“推理”拆开
很多人一开始会把编排逻辑直接写在推理调用的回调里,比如“推理返回了工具调用请求,就在回调里直接执行工具”。这种做法在 demo 阶段没问题,但一旦任务变复杂——比如需要并行调用多个工具、需要根据中间结果动态调整后续步骤、需要支持超时重试——代码就会变成一团乱麻。
把编排层独立出来之后,你可以用状态机来描述任务流转,每个状态对应一个明确的动作,状态之间的迁移条件清晰可测。推理层只负责“给定上下文,返回下一步动作”,不关心这个动作之后会发生什么。这样即使你后面把推理引擎从 ONNX Runtime 换成 TensorRT,编排层的代码一行都不用改。
3. C++ 在这个场景里到底赢在哪里
3.1 延迟的确定性比绝对速度更重要
Python 的异步框架(asyncio)在 IO 密集型场景下表现不错,但一旦涉及 CPU 密集的预处理(比如 tokenization、向量检索),GIL 就会成为瓶颈。更麻烦的是,Python 的 GC 和解释器调度会引入不可预测的延迟抖动。我实测过一个简单的意图分类任务,Python 版本 P99 延迟是 P50 的 8 倍,而 C++ 版本这个比值只有 2.3 倍。
对于 Agent 来说,延迟的确定性往往比平均延迟更重要。用户能接受稳定的 100ms 响应,但很难接受忽快忽慢的体验。C++ 没有 GC 停顿,内存分配可控,线程调度直接映射到操作系统原语,这些特性让延迟曲线更加平滑。
3.2 内存 footprint 的硬约束
我那个边缘设备上的 Agent,整个进程的内存预算只有 128MB。Python 解释器本身就要吃掉 20-30MB,加上依赖库和模型权重,根本塞不下。C++ 版本把运行时开销压到了 5MB 以内,剩下的全留给模型和业务逻辑。
即使不在嵌入式场景,内存 footprint 也直接影响部署成本。同样一台 8GB 的机器,Python Agent 可能只能跑 20 个实例,C++ 版本能跑 80 个。在大规模部署时,这个差距就是真金白银。
3.3 和现有 C++ 生态的无缝集成
很多团队的推理引擎、特征工程、业务逻辑本来就是 C++ 写的。如果 Agent 用 Python 做,就需要在 Python 和 C++ 之间搭一层桥(pybind11、ctypes、gRPC),每次跨语言调用都有序列化和上下文切换的开销。用 C++ 写 Agent,这些调用就是普通的函数调用,零开销。
我现在的做法是:Agent 核心用 C++ 写,模型推理直接调 ONNX Runtime 的 C++ API,工具函数里有一半是直接链接进来的 C++ 库。整个链路没有任何跨语言边界,调试的时候 gdb 一把梭,堆栈清晰,变量可见。
注意:C++ 的编译和依赖管理确实比 Python 麻烦。我用的是 CMake + vcpkg 的组合,虽然配置起来有点繁琐,但一旦跑通,后续的构建和部署非常稳定。建议在项目初期就把构建系统搭好,不要等到代码写了几千行再回头折腾。
4. 阅读这套代码的推荐路线
4.1 先跑起来,再深入细节
不管你多急着看架构设计,第一步永远是把项目跑起来。我在仓库根目录放了一个scripts/bootstrap.sh,它会自动安装依赖、编译、下载测试模型、跑一个最小的 demo。这个过程大概需要 10-15 分钟,但能帮你确认环境没问题。
跑通 demo 之后,你会看到一个最简单的交互:输入一句话,Agent 返回一个回复。这时候不要急着看代码,先玩一玩,试试不同的输入,观察它的行为。比如输入“现在几点了”,看它会不会调用时间工具;输入“帮我算一下 23 乘以 47”,看它怎么处理数学计算。这些直观感受会帮你后面读代码时更有方向感。
4.2 从 main 函数开始,沿着调用链走一遍
C++ 项目的入口很明确,就是main函数。我的项目里main在src/app/main.cpp,做的事情很简单:解析命令行参数、加载配置、初始化 Agent、进入交互循环。从这里开始,沿着调用链往下走:
Agent::Init()做了什么初始化?Agent::Process(input)内部怎么流转?- 编排层的状态机在哪里被驱动?
- 推理层的接口长什么样?
- 工具是怎么被注册和调用的?
这条路径走下来,你对整体架构就有了骨架级的理解。不要一开始就钻进某个模块的细节里,先建立全局观。
4.3 重点读三个文件
如果时间有限,我建议优先读这三个文件:
src/core/orchestrator.cpp:编排层的核心实现,包含状态机定义、任务调度逻辑、错误处理策略。这个文件理解了,整个 Agent 的行为逻辑就清楚了。src/inference/engine.cpp:推理层的封装,展示了怎么把 LLM 推理抽象成一个统一的接口,以及怎么管理上下文窗口和 token 预算。src/tools/registry.cpp:工具注册表的实现,包含工具的定义、注册、查找、调用全流程。这个文件能帮你理解 Agent 怎么和外部世界交互。
这三个文件加起来大概 2000 行代码,但覆盖了 80% 的核心逻辑。读完它们,剩下的就是细节补充了。
4.4 调试和日志的阅读技巧
C++ 的调试信息比 Python 要“硬”一些,但一旦掌握,效率反而更高。我的项目里用了 spdlog 做日志,每个关键路径都有 DEBUG 级别的日志输出。跑 demo 的时候把日志级别调到 DEBUG,你能看到完整的任务流转过程:
[DEBUG] Orchestrator: received input, length=42 [DEBUG] Orchestrator: state transition IDLE -> PLANNING [DEBUG] Inference: building prompt, context_tokens=128 [DEBUG] Inference: inference completed, latency=45ms [DEBUG] Orchestrator: plan result: 2 steps [DEBUG] Orchestrator: state transition PLANNING -> TOOL_CALL [DEBUG] ToolRegistry: invoking tool 'calculator' with args {...} [DEBUG] ToolRegistry: tool 'calculator' returned 1081 [DEBUG] Orchestrator: state transition TOOL_CALL -> RESPONDING [DEBUG] Inference: generating final response [DEBUG] Orchestrator: state transition RESPONDING -> IDLE这条日志链就是 Agent 的“心电图”。当你遇到行为异常时,先看日志在哪一步断了或者走偏了,然后去对应的代码位置下断点。gdb 的break和print在这种场景下比 Python 的 pdb 更直接,因为你能看到真实的内存布局和线程状态。
5. 几个容易踩的坑和我的处理方式
5.1 上下文窗口管理不是简单的截断
LLM 的上下文窗口是有限的,Agent 在多轮对话和工具调用中会不断往上下文里塞东西。最简单的做法是“超过窗口就截断最老的消息”,但这样会丢掉关键信息。我的做法是分层管理:
- 系统提示词:永远保留,不参与截断。
- 最近 N 轮对话:完整保留,保证短期记忆。
- 工具调用结果:按重要性打分,低分的优先丢弃。
- 历史摘要:把更早的对话压缩成一段摘要,占用少量 token。
这套策略在src/inference/context_manager.cpp里实现,核心是一个优先级队列加一个摘要生成器。实测下来,比简单截断的对话连贯性好了很多。
5.2 工具调用的参数校验不能省
LLM 生成的工具调用参数经常有格式问题:数字被包在引号里、必填字段缺失、枚举值拼写错误。如果直接把这些参数传给工具函数,轻则报错,重则产生副作用。我在工具注册表里加了一层参数校验,每个工具定义自己的参数 schema,调用前先校验,不通过就返回错误信息让 LLM 重新生成。
这个校验层用 JSON Schema 描述,代码在src/tools/validator.cpp。虽然增加了一点开销,但避免了大量“LLM 生成垃圾参数导致工具崩溃”的问题。
5.3 错误处理要区分“可恢复”和“不可恢复”
Agent 执行过程中会遇到各种错误:推理超时、工具返回异常、网络中断。不是所有错误都应该让整个任务失败。我的分类是:
- 可恢复错误:比如工具参数格式错误、临时网络抖动。策略是重试或让 LLM 重新规划。
- 不可恢复错误:比如模型文件损坏、内存耗尽。策略是记录日志、返回友好错误、终止任务。
这个分类逻辑在编排层的状态机里体现为不同的状态迁移路径。可恢复错误会回到 PLANNING 状态重新规划,不可恢复错误直接跳到 ERROR 状态。
5.4 线程模型要提前想清楚
Agent 天然是并发的:多个用户请求、多个工具并行调用、推理和预处理重叠。C++ 里你可以用线程池、协程、异步 IO 各种方案,但不要一开始就追求极致并发。我的建议是先用最简单的同步模型跑通,然后逐步引入线程池处理工具调用,最后再考虑推理的异步化。
我现在的模型是:主线程负责编排状态机,工具调用走一个固定大小的线程池,推理调用是同步阻塞的(因为推理引擎本身通常已经做了内部并行)。这个模型简单、可预测,调试起来也方便。
6. 这套架构适合谁,不适合谁
6.1 适合的场景
- 边缘部署:设备资源有限,Python 运行时开销不可接受。
- 低延迟要求:需要稳定的 P99 延迟,不能有 GC 抖动。
- 深度集成:Agent 需要和现有 C++ 系统紧密耦合,跨语言调用成本高。
- 长期运行:Agent 需要 7x24 运行,对内存泄漏和稳定性要求极高。
6.2 不适合的场景
- 快速原型验证:Python 的生态和迭代速度无可替代。
- 模型训练和微调:这部分用 Python 工具链更成熟。
- 团队没有 C++ 经验:C++ 的调试和内存管理门槛不低,强行上马会拖慢进度。
- 业务逻辑频繁变化:C++ 的编译部署周期比 Python 长,不适合需求天天变的场景。
我个人的判断标准是:如果 Agent 的核心价值在于“智能”本身,用 Python;如果核心价值在于“工程化落地”,考虑 C++。两者不是对立的,很多团队的做法是 Python 做原型,C++ 做生产,中间用统一的接口协议对接。
7. 后续章节会覆盖什么
这个系列接下来会按模块展开,每一篇都聚焦一个具体的实现问题。大致规划是:
- 推理引擎的 C++ 封装:怎么把 ONNX Runtime 和 llama.cpp 统一到一个接口后面,怎么管理模型加载和卸载。
- 编排状态机的设计与实现:状态怎么定义、迁移怎么触发、怎么支持并行和超时。
- 工具系统的注册与调用:工具怎么定义、参数怎么校验、结果怎么回传。
- 上下文管理与记忆机制:token 预算怎么分配、历史怎么压缩、长期记忆怎么存储。
- 性能调优与压测:怎么定位瓶颈、怎么优化内存分配、怎么做延迟压测。
每一篇都会附带可运行的代码片段和实测数据,不是纯理论。如果你在跟着做的时候遇到问题,可以先看日志,再对照代码,大部分坑我都踩过了,文章里会标注出来。
最后说一句实在话:用 C++ 写 AI Agent 不是一条轻松的路,编译错误、内存问题、并发 bug 都会让你怀疑人生。但当你看到 Agent 在一个 128MB 的盒子上稳定运行、延迟曲线平直如尺、连续跑一周内存纹丝不动的时候,那种掌控感是 Python 给不了的。如果你也在这条路上,欢迎交流踩坑经验。