ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++ 构建 AI Agent:架构设计与核心模块选型实战

C++ 构建 AI Agent:架构设计与核心模块选型实战 1. 为什么不用 Python 而选 C 来写 AI Agent1.1 一个反直觉的选型决定大多数人听到AI Agent这个词第一反应就是用 Python。毕竟 LangChain、AutoGen、CrewAI 这些主流框架全是 Python 生态各种大模型的官方 SDK 也是 Python 优先。我一开始也是这么想的直到我真正动手写了一个需要长时间驻留、频繁调度、还要跟本地硬件打交道的 Agent 之后才发现 Python 在某些场景下确实让人难受。具体难受在哪我列几个我实际遇到的问题。第一是部署体积一个稍微完整点的 Python Agent 项目光依赖就几百兆打包成可执行文件之后动辄上 G分发给别人用的时候非常尴尬。第二是并发模型Python 的 GIL 决定了你在单进程里做真正的并行计算很别扭虽然可以用多进程或者 asyncio 绕但代码复杂度上去了。第三是跟底层系统交互比如你要调用某个 C 语言写的推理库、要操作串口、要做高性能的日志采集Python 的 ctypes 或者 pybind11 虽然能用但总隔着一层。C 就不一样了。它编译出来就是一个二进制文件没有运行时依赖的烦恼静态链接的情况下性能可控内存布局透明跟操作系统和硬件打交道是它的主场。当然C 写 Agent 也有代价——开发效率低、字符串处理繁琐、没有现成的 LLM 生态。所以这不是一个谁更好的问题而是一个什么场景适合什么工具的问题。1.2 C 写 Agent 真正合适的几类场景我把适合用 C 写 Agent 的场景归纳成这么几类你可以对照自己的需求看看边缘设备上的常驻 Agent比如跑在工控机、机器人控制器、嵌入式板子上的智能调度程序。这类设备内存有限、CPU 性能一般Python 解释器的开销和内存占用往往不可接受。需要跟本地推理引擎深度集成的 Agent比如你用 llama.cpp、ONNX Runtime、TensorRT 这类 C/C 写的推理框架直接在同一个进程里调用省去跨语言调用的开销和序列化成本。对延迟极度敏感的实时 Agent比如游戏 AI、高频交易辅助、实时语音交互。这类场景下 Python 的 GC 停顿和解释执行开销是实打实的瓶颈。需要分发给终端用户且不想让用户装环境的工具一个静态编译的 C 二进制双击就能跑用户体验比先装 Python 再 pip install 一堆东西好太多。反过来如果你的 Agent 主要是做原型验证、快速迭代 prompt、集成各种 SaaS API那 Python 依然是更明智的选择。选型这件事最怕的就是为了技术而技术。1.3 这个系列要带你走的路这个系列我打算从零开始用 C 搭一个能实际跑起来的 AI Agent。不是那种玩具级的打印一句话的 demo而是一个有完整架构、能接入大模型、能管理工具调用、能维护对话状态、能处理并发任务的骨架。整个系列会分成若干篇每篇聚焦一个模块最后拼成一个完整的东西。阅读路线我建议是这样的先看整体架构也就是本篇搞清楚各个模块的职责和它们之间的数据流然后按依赖顺序逐个深入从最底层的网络通信和 JSON 处理开始往上到 LLM 客户端封装、工具注册与调度、对话上下文管理、任务编排最后是打包和部署。每一篇都会有可编译的代码你可以跟着敲也可以直接拿去做二次开发。提示这个系列假设你有 C 基础知道类、模板、智能指针、STL 容器怎么用但不需要你懂 AI 或者大模型原理。所有跟模型相关的部分我都会解释清楚它在干什么。2. 一个 C AI Agent 的整体架构长什么样2.1 从输入一句话到输出一个动作看数据流在讲模块划分之前我先用一条完整的数据流把整个 Agent 的工作过程串一遍这样你对架构的理解会更直观。用户输入一句话比如帮我查一下明天北京的天气如果下雨就提醒我带伞。这句话进入 Agent 之后大致会经历这么几个阶段输入接收层把用户的输入可能是命令行、HTTP 请求、串口消息统一成内部的消息结构。上下文组装把当前输入、历史对话、系统提示词、可用工具的描述拼成一个符合大模型 API 格式的请求体。LLM 调用通过 HTTP 把请求发给大模型服务拿到返回的文本或者结构化输出。响应解析判断模型返回的是普通文本回复还是一个工具调用请求比如调用 get_weather 工具参数是 city北京, date明天。工具调度如果是工具调用找到对应的工具函数执行它拿到结果。结果回灌把工具执行结果作为新一轮的输入再发给模型让模型基于结果生成最终回复。输出返回把最终回复返回给用户同时把这一轮的所有消息存入上下文。这个循环可能会转好几圈模型可能连续调用多个工具直到模型认为不需要再调用工具为止。理解了这个循环你就理解了 Agent 的本质——它就是一个LLM 工具 循环的组合。2.2 模块划分与职责边界基于上面的数据流我把整个系统拆成这么几个模块每个模块职责单一模块之间通过明确的接口通信模块职责依赖方向网络层HTTP/HTTPS 请求、连接池、超时重试被 LLM 客户端依赖JSON 层请求体构造、响应解析、结构化数据提取被上下层广泛依赖LLM 客户端封装特定大模型 API处理鉴权、流式响应依赖网络层和 JSON 层工具系统工具注册、参数校验、执行调度依赖 JSON 层上下文管理消息历史存储、token 预算控制、裁剪策略被 Agent 核心依赖Agent 核心主循环、状态机、错误处理依赖以上所有接入层命令行/HTTP/其他入口依赖 Agent 核心这个划分的关键在于依赖方向是单向的上层依赖下层下层不知道上层的存在。这样你换一个 LLM 提供商只需要改 LLM 客户端换一个接入方式只需要改接入层工具系统完全不用动。2.3 为什么不用现成的 C HTTP 库而自己封装这里我要专门说一下网络层。C 的 HTTP 客户端库其实不少libcurl、cpp-httplib、Boost.Beast 都能用。我的建议是底层用 libcurl但自己包一层薄薄的接口。原因有几个。第一libcurl 是 C 接口直接用它写业务代码会很啰嗦每个请求都要设置一堆 option还要处理回调。包一层之后业务代码只需要auto resp http_client.post(url, headers, body);这么一行。第二你需要在网络层统一处理超时、重试、代理配置、SSL 证书这些横切关注点如果散落在业务代码里会很难维护。第三将来如果要换成异步 IO 或者换一个 HTTP 库只需要改这一层。cpp-httplib 我也用过它胜在 header-only、上手快但它的连接管理和性能在长时间高频请求下不如 libcurl 稳。Boost.Beast 功能强大但学习曲线陡而且会把 Boost 的依赖带进来编译时间会明显变长。所以综合下来libcurl 自封装是性价比最高的方案。2.4 目录结构约定一个清晰的项目结构能省掉很多沟通成本。我习惯用这样的布局agent/ ├── CMakeLists.txt ├── third_party/ # 第三方库libcurl、nlohmann/json 等 ├── include/agent/ # 对外暴露的头文件 │ ├── http_client.h │ ├── json_util.h │ ├── llm_client.h │ ├── tool_registry.h │ ├── context.h │ └── agent.h ├── src/ # 实现文件 │ ├── http_client.cpp │ ├── llm_client.cpp │ ├── tool_registry.cpp │ ├── context.cpp │ └── agent.cpp ├── tools/ # 具体工具实现 │ ├── weather_tool.cpp │ └── calc_tool.cpp ├── apps/ # 可执行入口 │ └── main.cpp └── tests/ # 单元测试include和src分离的好处是将来你要把这个 Agent 做成库给别人用别人只需要包含include目录就行实现细节完全隐藏。tools单独放是因为工具是会不断增加的跟核心框架分开新增工具时不用动核心代码。3. 核心模块的技术选型与踩坑预判3.1 JSON 处理为什么我最终选了 nlohmann/json跟大模型打交道本质上就是不停地构造 JSON 和解析 JSON。请求体是 JSON响应是 JSON工具的参数和返回值也是 JSON。所以 JSON 库的选择直接决定了你写代码的舒适度。我对比过几个主流的 C JSON 库库优点缺点适用场景nlohmann/jsonheader-only、API 优雅、STL 风格编译慢、大 JSON 性能一般业务代码、快速开发RapidJSON性能极强、内存占用低API 繁琐、容易用错高性能解析、大文件simdjson解析速度最快只读、不能构造只读场景Boost.JSON性能与易用性平衡依赖 Boost已经用 Boost 的项目我最终选 nlohmann/json理由很实际Agent 场景下的 JSON 都不大几 KB 到几十 KB性能根本不是瓶颈而开发效率是。nlohmann/json 的 API 写起来跟 Python 的 dict 差不多j[messages][0][role] user这种写法非常直观。RapidJSON 虽然快但那个rapidjson::Value和rapidjson::Document的用法每次都要查文档写业务逻辑的时候很打断思路。注意nlohmann/json 是 header-only 的但它的编译时间确实感人。我的做法是把它放在一个单独的编译单元里用extern template显式实例化常用的类型其他文件只包含前置声明。这样能把编译时间从几分钟降到几十秒。3.2 HTTP 客户端libcurl 的初始化和复用libcurl 有个坑我必须提前说全局初始化只能做一次而且必须在多线程环境下正确使用。curl_global_init要在程序启动时调用一次curl_global_cleanup在退出时调用一次。如果你在多个线程里各自调用curl_easy_init那是没问题的但curl_global_init绝对不能重复调用。另一个坑是连接复用。如果你每次请求都curl_easy_init然后curl_easy_cleanup那每次都要重新建立 TCP 连接和 TLS 握手延迟会很高。正确的做法是维护一个CURL*句柄池或者用curl_multi接口做异步。我在网络层里实现了一个简单的句柄池每个线程持有一个CURL*请求完成后不销毁下次复用。这样连接和 TLS 会话都能复用实测延迟能降一半以上。超时设置也是必须的。大模型接口有时候会卡住如果不设超时你的 Agent 就会一直挂在那里。我一般设CURLOPT_TIMEOUT为 60 秒总超时CURLOPT_CONNECTTIMEOUT为 10 秒连接超时。流式响应的话总超时要设长一点但连接超时还是要短。3.3 大模型 API 的封装策略不同厂商的 API 格式差异其实不大基本都是 OpenAI 那套messages数组的格式。但细节上还是有区别比如鉴权头的字段名、流式响应的 SSE 格式、工具调用的 JSON schema 结构。我的封装策略是定义一个内部的统一接口每个厂商写一个适配器。内部接口大概长这样struct ChatMessage { std::string role; // system / user / assistant / tool std::string content; std::string tool_call_id; // 工具消息才有 std::vectorToolCall tool_calls; // assistant 消息才有 }; struct ChatRequest { std::vectorChatMessage messages; std::vectorToolDef tools; std::string model; double temperature; bool stream; }; struct ChatResponse { ChatMessage message; int prompt_tokens; int completion_tokens; std::string finish_reason; }; class LLMClient { public: virtual ChatResponse chat(const ChatRequest req) 0; virtual void chat_stream(const ChatRequest req, std::functionvoid(const std::string) on_delta) 0; virtual ~LLMClient() default; };这样上层 Agent 核心只跟LLMClient打交道换厂商的时候只需要换一个实现类。工具调用的 schema 转换也放在适配器里因为不同厂商对工具定义的字段名可能不一样。3.4 工具系统的设计注册、校验、执行工具系统是 Agent 的能力来源。一个设计良好的工具系统应该满足几个要求新增工具不需要改核心代码、工具的参数有类型校验、工具执行出错不会让整个 Agent 崩溃。我的做法是用一个注册表加工厂模式。每个工具继承一个基类class Tool { public: virtual std::string name() const 0; virtual std::string description() const 0; virtual json parameters_schema() const 0; virtual json execute(const json args) 0; virtual ~Tool() default; };然后有一个ToolRegistry单例工具在启动时注册进去。Agent 核心拿到模型返回的工具调用请求后从注册表里找到对应工具先用parameters_schema校验参数再调用execute。执行的时候用 try-catch 包住任何异常都转成一个错误 JSON 返回给模型让模型自己决定怎么处理。这里有个经验工具的 description 写得越清楚模型调用得越准。我见过太多人工具描述就写一句查询天气结果模型经常传错参数。正确的写法是把参数的含义、格式、取值范围都写清楚甚至可以给几个例子。这跟写 API 文档是一个道理模型就是你的 API 调用方。4. 阅读路线按什么顺序啃下这个项目4.1 先跑通再深入别一上来就抠细节我给的建议可能跟很多人不一样先把整个项目跑起来再回头逐个模块深入。很多人学新东西的习惯是从第一个文件开始逐行读读到一半就迷失在细节里了。Agent 这种系统模块之间的交互比单个模块的实现更重要你先要建立全局观。具体怎么做先按我给的 CMakeLists 把项目编译出来配一个 API key跑一个最简单的你好对话。跑通之后你会有成就感也知道整个东西是能工作的。然后再回头看代码这时候你读每一行都知道它在整个流程里的位置理解会快很多。4.2 各模块的依赖顺序与学习优先级如果你要按模块深入我建议的顺序是这样的JSON 层半天这是最基础的先搞清楚怎么构造请求体、怎么解析响应。这部分不难但必须熟练因为后面到处都在用。网络层一天理解 libcurl 的基本用法、句柄复用、超时和重试。这部分踩坑最多但一旦搞定就是一劳永逸。LLM 客户端一天把请求发出去、把响应解析出来。先做非流式的跑通之后再改成流式。工具系统一天写一两个简单的工具比如计算器、时间查询理解注册和调度的流程。上下文管理半天消息历史的存储和裁剪。这部分逻辑不复杂但策略设计需要思考。Agent 核心两天把上面所有东西串起来实现主循环。这是最核心的部分也是最能体现设计功力的地方。接入层和打包半天命令行入口、CMake 打包、静态链接。这个顺序的好处是每一步都建立在前一步的基础上不会出现这个函数调用的东西我还没学的情况。4.3 每个阶段应该达到的验证标准学东西最怕的就是以为自己懂了。我给每个阶段定一个可验证的标准你达到了再往下走JSON 层能独立写出构造一个带嵌套数组和对象的 JSON并能从中提取出指定字段。网络层能发一个 POST 请求到任意 HTTP 接口拿到响应并且能正确处理超时和连接失败。LLM 客户端能跟大模型完成一轮对话拿到回复。工具系统能注册一个新工具模型能正确调用它并拿到结果。上下文管理能维护多轮对话并且在消息超过预算时正确裁剪。Agent 核心能完成一个需要连续调用两个工具才能回答的问题。这些标准看起来简单但每一个都跑通之后你对整个系统的理解就到位了。4.4 常见的学习误区最后说几个我观察到的、学这类项目时容易踩的误区。第一个误区是过度设计。很多人一开始就想搞一个支持插件、支持热更新、支持分布式的大框架结果写了两个月还在搭架子。我的建议是先写一个能跑的最简版本哪怕所有代码都在一个文件里跑通之后再重构。第二个误区是忽视错误处理。网络请求会失败、模型会返回格式不对的内容、工具会抛异常这些都是常态。如果你不处理程序跑着跑着就崩了。错误处理不是可选项是必选项。第三个误区是不写测试。C 的调试成本比 Python 高很多一个段错误可能要查半天。给核心模块写单元测试尤其是 JSON 解析和工具参数校验这种纯逻辑的部分能帮你省下大量时间。我用的是 Catch2header-only集成起来很方便。第四个误区是死磕性能。C 确实快但 Agent 的瓶颈几乎永远在网络 IO 和模型推理上不在你的代码上。与其花时间优化字符串拼接不如把超时和重试做好。性能优化要等到真的出现瓶颈再做而且要基于 profiling 数据不能凭感觉。5. 环境准备与第一个可编译的骨架5.1 编译器和构建工具的最低要求C 写 Agent编译器版本不能太老。我要求至少 C17因为要用到std::optional、std::string_view、结构化绑定这些特性。如果编译器支持 C20 更好std::format和 concepts 能让代码干净不少。具体来说GCC 9 以上Clang 10 以上MSVC 2019 16.8 以上构建工具用 CMake版本至少 3.16。Windows 上我建议用 MSVC 或者 MinGW-w64不要用那些老掉牙的 Dev-C 自带编译器它对 C17 的支持不完整会让你在莫名其妙的地方卡住。VS Code 配置 C/C 环境的话装好编译器之后配一下c_cpp_properties.json和tasks.json就行网上教程很多这里不展开。5.2 第三方依赖的引入方式这个项目依赖的第三方库不多主要是三个libcurl网络、nlohmann/jsonJSON、Catch2测试。引入方式我推荐用 CMake 的FetchContent这样不用手动下载和配置CMake 会自动拉取和编译。cmake_minimum_required(VERSION 3.16) project(cpp_agent CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) include(FetchContent) FetchContent_Declare( json GIT_REPOSITORY https://github.com/nlohmann/json.git GIT_TAG v3.11.3 ) FetchContent_MakeAvailable(json) find_package(CURL REQUIRED) add_library(agent_core src/http_client.cpp src/llm_client.cpp src/tool_registry.cpp src/context.cpp src/agent.cpp ) target_include_directories(agent_core PUBLIC include) target_link_libraries(agent_core PUBLIC CURL::libcurl nlohmann_json::nlohmann_json) add_executable(agent_app apps/main.cpp) target_link_libraries(agent_app PRIVATE agent_core)libcurl 我建议用系统的包管理器装Ubuntu 上apt install libcurl4-openssl-devmacOS 上brew install curl因为自己编译 libcurl 还要处理 SSL 后端比较麻烦。5.3 一个最小可运行的 main 函数在深入各个模块之前先写一个最小的 main验证环境是通的#include iostream #include nlohmann/json.hpp int main() { nlohmann::json j; j[messages] nlohmann::json::array(); j[messages].push_back({{role, user}, {content, 你好}}); j[model] your-model-name; std::cout j.dump(2) std::endl; return 0; }编译运行如果能看到格式化输出的 JSON说明编译器和 JSON 库都没问题。这一步看着简单但能帮你排除掉一大堆环境问题。我见过太多人一上来就写复杂代码结果编译报错几十个根本分不清是环境问题还是代码问题。5.4 配置管理API key 不要硬编码最后说一个容易被忽视但很重要的点API key 绝对不能硬编码在代码里。我见过有人把 key 直接写在源码里然后传到公开仓库结果被人盗刷。正确的做法是从环境变量或者配置文件读取。我的做法是支持两种方式优先读环境变量读不到再读同目录下的config.json。config.json加到.gitignore里仓库里只放一个config.example.json作为模板。这样既方便本地开发又不会泄露密钥。std::string get_api_key() { const char* env std::getenv(AGENT_API_KEY); if (env *env) return env; std::ifstream f(config.json); if (f.is_open()) { auto cfg nlohmann::json::parse(f); return cfg.value(api_key, ); } return ; }这个函数虽然简单但它体现的是一个工程习惯——敏感信息跟代码分离。这个习惯在你把项目分享给别人或者部署到服务器上的时候会帮你避免很多麻烦。6. 关于架构设计的一点个人体会写到这里整个架构的轮廓应该比较清楚了。我想再分享几点在设计和实现过程中体会比较深的东西这些是文档里不会写、但实际做项目时很关键的。第一点是接口要稳定实现可以变。我在设计每个模块的时候都会先想清楚它的接口长什么样然后才去写实现。接口一旦定下来就尽量不改。因为接口一改所有调用方都要跟着改牵一发动全身。比如LLMClient的chat方法我一开始就设计成接收一个ChatRequest返回一个ChatResponse中间不管怎么实现流式、怎么处理重试接口都不变。这样上层代码写一次就不用动了。第二点是错误要显式不要用异常做流程控制。C 的异常机制很强大但滥用会让代码难以理解。我的原则是真正的异常情况比如网络断了、内存分配失败用异常可预期的错误比如模型返回了工具调用、工具执行返回了错误信息用返回值表达。这样读代码的人一眼就能看出哪些是正常流程哪些是异常分支。第三点是日志要打够但不要打太多。Agent 的运行过程涉及很多步骤出问题的时候如果没有日志会很难查。我在每个关键节点都打了日志请求发出前、响应收到后、工具调用前后、上下文裁剪时。但日志级别要分清楚DEBUG 级别的日志默认不输出需要的时候再打开。否则日志文件会迅速膨胀反而影响排查。第四点是先让它工作再让它优雅。我见过太多人包括我自己早期在写第一版的时候就想把代码写得完美结果进度极慢。正确的做法是先写一个能跑的版本哪怕很丑跑通之后再重构。重构的时候你已经有测试了改起来心里有底。这个项目本身也是这么迭代出来的第一版所有代码都在一个文件里后来才慢慢拆成现在这个结构。如果你跟着这个系列走下来最后应该能得到一个能实际使用的 C AI Agent 骨架。它不会像那些成熟的 Python 框架一样功能齐全但它的每一行代码你都能看懂、能改、能扩展。对于想深入理解 Agent 底层机制、或者需要在 C 环境里落地 Agent 的人来说这比直接用现成框架有价值得多。下一篇我会从网络层开始把 libcurl 的封装细节和踩过的坑讲透。
返回列表