资讯动态

C++ 手写 AI 编程 Agent(3):工具注册表与文件操作工具集

发布时间:2026/10/17 11:04:21 来源:尧图企业网站定制
1. 为什么工具注册表是 C AI Agent 的分水岭写 C AI 编程 Agent 到第三篇很多人会卡在同一个地方模型能聊天了但让它“读一下 main.cpp”“把第 42 行的 log 改成 fmt::print”它就只会回你一段看起来很像代码的文字而不是真的动手。问题不在模型而在你还没给它一套工具注册表ToolRegistry和文件操作工具集。工具注册表是什么一句话它是 Agent 的“能力清单 调度中心”。每个工具用统一结构描述自己叫什么、干什么、需要哪些参数注册表负责把这些描述翻译成模型能看懂的 JSON Schema并在模型返回tool_call时找到对应函数执行。适合谁适合已经用 C 写完 ReAct 循环、准备让 Agent 真正落地到本地代码助手场景的开发者。我试过最朴素的写法——一个if (name read_file)接一个else if工具到第五个就开始失控参数校验散落各处、错误信息不统一、模型拿不到准确的 schema 就乱编参数。所以这一篇我们把工具层做扎实可复制的注册表结构、五个文件工具、错误处理、以及用 TaoToken 做一次真实的工具调用验证。核心检索词先摆出来C AI Agent 工具注册表、文件操作工具集、Function Calling schema。这三个词贯穿全文你照着敲完就能得到一个可扩展的工具层。2. TaoToken 前置给 Agent 一个稳定的模型出口工具层写完后必须接一个真实模型才能验证否则你只是在自娱自乐。这里用 TaoToken 作为模型出口原因是它兼容 OpenAI 的 Function Calling 协议我们的to_function_schema()输出可以直接塞进请求体不用改结构。先拿 Key。打开 https://taotoken.net/api-keys 登录后创建一个 API Key复制保存。注意 Key 只在创建时完整显示一次丢了就重建。拿到 Key 后两个地址记牢Base URLhttps://taotoken.net/api注意不要加 UTM代码里用干净地址API Keysk-开头的那串模型 ID 选一个支持工具调用的比如claude-sonnet-4-5或gpt-4o这类。你可以在 https://taotoken.net/models 里确认当前可用的模型列表别照抄我这里的名字以页面为准。如果你打算长期跑编码 Agent、频繁调用工具可以看下 Coding Planhttps://taotoken.net/coding-plan 按量或包月自己权衡。只是想先验证工具注册表能不能跑通用 API Key 按次调用就够了。接入文档在 https://taotoken.net/doc 里面写了请求头格式和 Function Calling 的字段说明遇到 401 或参数报错先翻这里。一个提醒TaoToken 是模型调用出口不是编辑器插件它不会帮你改代码改代码的是你自己写的edit_file工具。这个边界要清楚否则调试时会怀疑错方向。3. 可复制配置注册表结构 文件工具集这一节是全文核心所有代码可直接编译。依赖只有两个nlohmann/json和fmt。CMake 里这样写find_package(nlohmann_json REQUIRED) find_package(fmt REQUIRED) target_link_libraries(agent PRIVATE nlohmann_json::nlohmann_json fmt::fmt)3.1 工具定义结构先定义工具的统一形态。关键是parameters_schema用 JSON Schema 描述参数to_function_schema()负责转成 OpenAI Function Calling 格式。// include/types.h #pragma once #include string #include functional #include nlohmann/json.hpp using json nlohmann::json; struct ToolResult { bool success; std::string output; static ToolResult Ok(const std::string out) { return {true, out}; } static ToolResult Error(const std::string err) { return {false, err}; } }; using ToolHandler std::functionToolResult(const json params); struct ToolDefinition { std::string name; std::string description; json parameters_schema; ToolHandler handler; json to_function_schema() const { return { {type, function}, {function, { {name, name}, {description, description}, {parameters, parameters_schema} }} }; } };3.2 注册表实现注册表用unordered_map存工具execute里做两件事找不到工具返回可用列表执行抛异常时兜住转成错误结果。这两点直接决定模型能不能自我纠错。// include/tool_registry.h #pragma once #include types.h #include unordered_map #include stdexcept #include fmt/core.h class ToolRegistry { public: void register_tool(ToolDefinition tool) { if (tools_.count(tool.name)) { throw std::runtime_error( fmt::format(Tool {} already registered, tool.name)); } tools_[tool.name] std::move(tool); } ToolResult execute(const std::string name, const json params) const { auto it tools_.find(name); if (it tools_.end()) { return ToolResult::Error( fmt::format(Unknown tool: {}. Available: {}, name, names())); } try { return it-second.handler(params); } catch (const std::exception e) { return ToolResult::Error( fmt::format(Tool {} failed: {}, name, e.what())); } } json get_tools_schema() const { json arr json::array(); for (const auto [name, def] : tools_) arr.push_back(def.to_function_schema()); return arr; } bool has_tool(const std::string name) const { return tools_.count(name) 0; } size_t size() const { return tools_.size(); } private: std::unordered_mapstd::string, ToolDefinition tools_; std::string names() const { std::string s; for (const auto [n, _] : tools_) s n ; return s; } };3.3 文件工具集五个工具read_file、write_file、edit_file、list_directory、search_in_files。每个都做路径穿越检查这是本地 Agent 的安全底线。// src/file_tools.cpp #include types.h #include fstream #include sstream #include filesystem #include regex #include fmt/core.h namespace fs std::filesystem; static bool safe_path(const std::string p) { return p.find(..) std::string::npos; } ToolDefinition make_read_file_tool() { return { read_file, Read a files content with line numbers. Supports line range., {{type, object}, {properties, { {path, {{type, string}, {description, File path}}}, {start_line, {{type, integer}, {description, 1-based start}}}, {end_line, {{type, integer}, {description, 1-based end}}} }}, {required, {path}}}, [](const json p) - ToolResult { std::string path p.at(path).getstd::string(); if (!safe_path(path)) return ToolResult::Error(Path traversal denied); if (!fs::exists(path)) return ToolResult::Error(File not found: path); std::ifstream f(path); if (!f.is_open()) return ToolResult::Error(Cannot open: path); int start p.value(start_line, 1); int end p.value(end_line, INT_MAX); std::stringstream out; std::string line; int n 0; while (std::getline(f, line)) { n; if (n start n end) out fmt::format({:6} | {}\n, n, line); } return ToolResult::Ok(out.str()); } }; } ToolDefinition make_write_file_tool() { return { write_file, Write content to a file, creating parent dirs. Backs up existing file., {{type, object}, {properties, { {path, {{type, string}, {description, File path}}}, {content, {{type, string}, {description, Content}}} }}, {required, {path, content}}}, [](const json p) - ToolResult { std::string path p.at(path).getstd::string(); std::string content p.at(content).getstd::string(); if (!safe_path(path)) return ToolResult::Error(Path traversal denied); fs::path fp(path); if (fp.has_parent_path()) fs::create_directories(fp.parent_path()); if (fs::exists(path)) fs::copy_file(path, path .bak, fs::copy_options::overwrite_existing); std::ofstream f(path); if (!f.is_open()) return ToolResult::Error(Cannot write: path); f content; return ToolResult::Ok(fmt::format(Wrote {} bytes to {}, content.size(), path)); } }; } ToolDefinition make_edit_file_tool() { return { edit_file, Replace exact old_string with new_string. Fails if not unique., {{type, object}, {properties, { {path, {{type, string}, {description, File path}}}, {old_string, {{type, string}, {description, Exact text to find}}}, {new_string, {{type, string}, {description, Replacement}}} }}, {required, {path, old_string, new_string}}}, [](const json p) - ToolResult { std::string path p.at(path).getstd::string(); std::string old_s p.at(old_string).getstd::string(); std::string new_s p.at(new_string).getstd::string(); std::ifstream in(path); if (!in.is_open()) return ToolResult::Error(Cannot open: path); std::string content((std::istreambuf_iteratorchar(in)), std::istreambuf_iteratorchar()); in.close(); size_t pos content.find(old_s); if (pos std::string::npos) return ToolResult::Error(old_string not found in path); if (content.find(old_s, pos 1) ! std::string::npos) return ToolResult::Error(old_string not unique, add more context); content.replace(pos, old_s.length(), new_s); std::ofstream out(path); out content; return ToolResult::Ok(Edited path); } }; } ToolDefinition make_list_directory_tool() { return { list_directory, List files and directories at a path., {{type, object}, {properties, { {path, {{type, string}, {description, Directory path}}} }}, {required, {path}}}, [](const json p) - ToolResult { std::string path p.at(path).getstd::string(); if (!fs::is_directory(path)) return ToolResult::Error(Not a directory: path); std::stringstream out; for (const auto e : fs::directory_iterator(path)) { out (e.is_directory() ? [DIR] : [FILE] ) e.path().filename().string() \n; } return ToolResult::Ok(out.str()); } }; } ToolDefinition make_search_in_files_tool() { return { search_in_files, Search a regex pattern across files in a directory., {{type, object}, {properties, { {pattern, {{type, string}, {description, Regex}}}, {path, {{type, string}, {description, Directory}}} }}, {required, {pattern, path}}}, [](const json p) - ToolResult { std::string pattern p.at(pattern).getstd::string(); std::string path p.at(path).getstd::string(); std::regex re; try { re std::regex(pattern); } catch (const std::regex_error e) { return ToolResult::Error(fmt::format(Bad regex: {}, e.what())); } std::stringstream out; int hits 0; for (const auto e : fs::recursive_directory_iterator(path)) { if (!e.is_regular_file()) continue; std::ifstream f(e.path()); std::string line; int n 0; while (std::getline(f, line)) { n; if (std::regex_search(line, re)) { out e.path().string() : n : line \n; if (hits 50) return ToolResult::Ok(out.str()); } } } return ToolResult::Ok(hits ? out.str() : No matches.); } }; }3.4 注册与调用// src/main.cpp #include tool_registry.h #include fmt/core.h ToolDefinition make_read_file_tool(); ToolDefinition make_write_file_tool(); ToolDefinition make_edit_file_tool(); ToolDefinition make_list_directory_tool(); ToolDefinition make_search_in_files_tool(); int main() { ToolRegistry reg; reg.register_tool(make_read_file_tool()); reg.register_tool(make_write_file_tool()); reg.register_tool(make_edit_file_tool()); reg.register_tool(make_list_directory_tool()); reg.register_tool(make_search_in_files_tool()); fmt::print(Registered {} tools\n, reg.size()); auto r reg.execute(read_file, {{path, src/main.cpp}, {end_line, 5}}); fmt::print(success{}\n{}\n, r.success, r.output); auto bad reg.execute(read_file, {{path, ../etc/passwd}}); fmt::print(blocked{} msg{}\n, !bad.success, bad.output); return 0; }编译运行后你会看到注册了 5 个工具read_file返回带行号的内容路径穿越被拦下。到这里工具层就活了。4. 验证请求把工具 schema 发给模型跑通一次真实调用工具注册表本地能跑不代表模型能用。真正的验证是把get_tools_schema()的输出塞进请求体让模型决定调哪个工具再把模型返回的参数喂给execute。请求体长这样注意tools字段直接来自注册表{ model: claude-sonnet-4-5, messages: [ {role: user, content: 读一下 src/main.cpp 的前 10 行} ], tools: [ { type: function, function: { name: read_file, description: Read a files content with line numbers. Supports line range., parameters: { type: object, properties: { path: {type: string, description: File path}, start_line: {type: integer, description: 1-based start}, end_line: {type: integer, description: 1-based end} }, required: [path] } } } ] }用 curl 验证一次curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_KEY \ -H Content-Type: application/json \ -d request.json模型返回的tool_calls里会有function.name read_file和function.arguments {\path\:\src/main.cpp\,\end_line\:10}。你把这个 arguments 解析成 json直接丢给reg.execute(name, args)就完成了闭环。实测下来模型对read_file这种描述清晰的工具命中率很高但如果你把 description 写成“读取文件”它经常漏掉end_line参数导致读整个大文件把上下文撑爆。所以 description 里一定要写清“supports line range”这是给模型看的提示词不是给人看的注释。验证成功的标志模型返回 tool_call你的 execute 返回带行号的文件内容你把结果作为role: tool的消息回传模型基于内容回答用户。四步走通工具层就算接上了。想单独测模型对工具的理解可以用模型对话页面 https://taotoken.net/chat 手动发一条带工具描述的消息看它怎么选参数比写代码调试快。5. 本篇常见错排查报错一Unknown tool: read_file模型返回的工具名和你注册的不一致。常见原因是注册时用了驼峰readFile但 schema 里模型看到的是read_file或者你注册顺序里漏了某个工具。排查方法打印reg.get_tools_schema()确认 name 字段和模型返回的完全一致。工具名统一用 snake_case别混。报错二old_string not found in xxx.cppedit_file最常见的坑。模型给的 old_string 少了缩进或换行或者文件里是 CRLF 而模型给的是 LF。解决在 description 里明确写“must match exactly including whitespace”并在错误信息里提示模型重新读取该行。更稳的做法是让模型先read_file拿到带行号的原文再基于原文构造 old_string。报错三401 UnauthorizedKey 没带对或带了空格。检查Authorization: Bearer sk-xxx中间是一个空格Key 没有换行。如果用的是环境变量确认echo $TAOTOKEN_KEY输出正常。401 和工具层无关是模型出口的鉴权问题翻接入文档 https://taotoken.net/doc 对照请求头。报错四local proxy failed或连接超时请求发不出去通常是 Base URL 写错。正确是https://taotoken.net/api不要多加/v1之外的路径也不要在代码里拼 UTM 参数。如果你本地有网络层工具干扰先关掉再测。报错五reading choices空指针模型返回体里没有choices字段多半是请求被拒或返回了错误对象。打印完整响应体看error字段。常见是模型 ID 写错或者tools字段格式不对比如漏了type: function外层。对照第 4 节的请求体逐字段核对。报错六Tool xxx already registered重复注册。检查是不是在循环里反复调register_tool或者多个源文件都注册了同一工具。注册应该只在初始化阶段做一次。排查顺序建议先确认工具本地 execute 能跑通再接模型模型报错先看是不是 401 或 URL 问题再看 schema 格式最后才怀疑工具逻辑。这样能省掉大量来回。6. 把工具层接进你的 Agent 主循环工具注册表和文件工具集写完后下一步是把它接进 ReAct 主循环模型返回 tool_calls你遍历每个 call执行reg.execute把结果包成role: tool的消息追加到对话历史再发下一轮请求直到模型不再调工具、直接给出最终回答。这里有个容易忽略的点工具执行结果要截断。read_file读一个 5000 行的文件输出几十万字符直接塞回上下文会爆 token。建议在工具层加一个输出上限比如超过 8000 字符就截断并提示“output truncated, use line range”。这个策略比在模型侧限制更可靠。另外edit_file执行成功后最好把 diff 一起返回。模型看到 diff 能确认自己改对了用户也能在 Human-in-the-Loop 模式下审核。diff 生成可以用简单的行级 LCS不必上完整 Myers 算法够用就行。如果你要长期跑编码 Agent把工具调用日志落盘记录每次的 name、参数、耗时、成功与否。出问题时这份日志比任何猜测都有用。Coding Plan 页面 https://taotoken.net/coding-plan 里有按量方案配合日志能算出每个任务的真实成本。最后一句实在话工具注册表的价值不在“能注册”而在“注册后模型能稳定选对、参数给对、错误能自纠”。把 description 写清楚、错误信息写具体、输出做截断这三点做到你的 C Agent 才算真正有了手脚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑