From b94460083ac1ffef22a60be4b0693b80ed02e154 Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Fri, 15 May 2026 11:24:42 +0800 Subject: [PATCH] =?UTF-8?q?v1.3=20Registry=20=E5=AE=9E=E7=8E=B0=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20+=20=E7=AC=AC=E4=B8=80=E6=8A=8A=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Registry 从纯接口升级为完整实现:Register/Execute/路由查找/错误自愈 - 新增 BaseTool 接口,统一工具契约 - 新增 ReadFileTool,首个真实工具实现(路径防穿越 + 截断保护) - 新增 String() 方法,Registry/Engine 调试可视化 - 新增 ToolCall 执行状态日志 - cmd/claw 切换为真实 Registry + ReadFileTool --- README.md | 25 ++++++++++ cmd/claw/main.go | 75 ++++++++++------------------- internal/engine/loop.go | 10 ++++ internal/tools/read_file.go | 85 +++++++++++++++++++++++++++++++++ internal/tools/registry.go | 95 +++++++++++++++++++++++++++++++++++-- main.go | 3 ++ 6 files changed, 240 insertions(+), 53 deletions(-) create mode 100644 internal/tools/read_file.go diff --git a/README.md b/README.md index ca6d138..a5cdf54 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,31 @@ func main() { ## 版本历史 +### v1.3 — Registry 实现重构与第一把真实工具 + +#### 变更 + +- **Registry 完整实现** — 从纯接口升级为 `registryImpl`,基于 `map[string]BaseTool` 实现 O(1) 路由查找、动态注册、错误自愈 +- **BaseTool 接口** — 定义工具的通用契约(`Name()` / `Definition()` / `Execute()`),所有具体工具统一实现 +- **首个真实工具 ReadFileTool** — 支持读取工作区文件,含路径穿越防护和 8000 字节截断 +- **调试可视化** — `AgentEngine` 和 `registryImpl` 实现 `String()` 方法,替代 16 进制内存地址 +- **ToolCall 执行日志** — 每轮工具执行后输出一行状态日志(`📋 ToolCall xxx: ✅ / ❌, 结果: ...`) +- **cmd/claw 真实化** — 从 mockRegistry 切换到 `tools.NewRegistry()` + `ReadFileTool` + +#### 踩坑记录 + +| 问题 | 原因 | 解决 | +|---|---|---| +| `fmt.Println(registry)` 输出 16 进制地址 | Go 默认打印指针/接口类型为内存地址,不展示内容 | 实现 `fmt.Stringer` 接口,自定义 `String()` 方法 | +| Registry 接口加 `Register` 后旧代码编译失败 | `main.go` 的 mockRegistry 没有实现新增的 `Register` 方法 | 为 mockRegistry 补充空实现 `Register(tool tools.BaseTool) {}` | +| 修改代码时需要同时兼容旧 mock 和新实现 | demo 代码和正式代码共用同一套接口 | 保留 mock 的兼容性(无操作 Register),新演示代码使用真实实现 | + +#### 经验教训 + +1. **接口先于实现,但实现要跟上** — v1.0 就定义了 `Registry` 接口,但一直没有真实实现,导致 cmd 只能靠 mock 跑。抽象要尽早落地 +2. **工具系统用 map 路由天然适合 Agent** — 大模型输出工具名 → 直接 map key 查找 → O(1) 路由,简单高效,也方便运行时动态挂载工具 +3. **`String()` 是 Go 调试的性价比之王** — 三行代码换来看日志时不用猜内存地址,投入产出比极高 + ### v1.2 — 真实模型接入与 Thinking 死循环修复 #### 变更 diff --git a/cmd/claw/main.go b/cmd/claw/main.go index 3b4f3cd..0de8f0b 100644 --- a/cmd/claw/main.go +++ b/cmd/claw/main.go @@ -4,63 +4,38 @@ import ( "context" "log" "os" - "fmt" "go-tiny-claw/internal/engine" - "go-tiny-claw/internal/schema" "go-tiny-claw/internal/provider" + "go-tiny-claw/internal/tools" ) - -// 伪造的工具注册表 (用于测试 Provider 的工具提取能力) -type mockRegistry struct{} - -func (m *mockRegistry) GetAvailableTools() []schema.ToolDefinition { - return []schema.ToolDefinition{ - { - Name: "get_weather", - Description: "获取指定城市的当前天气情况。", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{ - "type": "string", - }, - }, - "required": []string{"city"}, - }, - }, - } -} - -func (m *mockRegistry) Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult { - log.Printf(" -> [Mock 工具执行] 获取 %s 的天气中...\n", call.Name) - return schema.ToolResult{ - ToolCallID: call.ID, - Output: "API 返回:今天是晴天,气温 25 度。", - IsError: false, - } -} - func main() { - - fmt.Printf("11111") - workDir, _ := os.Getwd() - // 1. 初始化真实的 Provider大脑 (指向智谱 GLM-4.5) - // 这里你可以任意切换 NewZhipuClaudeProvider 或 NewZhipuOpenAIProvider,效果完全一致! - llmProvider := provider.DeepseekOpenAIProvider("deepseek-v4-flash") - // 2. 注入伪造的工具注册表 - registry := &mockRegistry{} + workDir, _ := os.Getwd() - // 3. 实例化并运行引擎,开启 EnableThinking = true (开启慢思考阶段!) - eng := engine.NewAgentEngine(llmProvider, registry, workDir, true) + // 1. 初始化真实的 Provider大脑 + // 这里你可以任意切换 NewZhipuClaudeProvider 或 NewZhipuOpenAIProvider,效果完全一致! + llmProvider := provider.DeepseekOpenAIProvider("deepseek-v4-flash") - // 设定测试任务 - prompt := "我想去北京跑步,帮我查查天气适合吗?" + // 3. 初始化真实的 Tool Registry + registry := tools.NewRegistry() - err := eng.Run(context.Background(), prompt) - if err != nil { - log.Fatalf("引擎运行崩溃: %v", err) - } -} \ No newline at end of file + // 4. 将真实的 ReadFile 工具挂载到注册表中 + readFileTool := tools.NewReadFileTool(workDir) + registry.Register(readFileTool) + + // 5. 实例化核心引擎,由于任务简单,我们关闭思考阶段 (EnableThinking = false) 以加快速度 + eng := engine.NewAgentEngine(llmProvider, registry, workDir, false) + + log.Printf("registry: %s\n", registry) + log.Printf("engine: %s\n", eng) + + // 6. 下发一个必须通过真实工具才能完成的任务 + prompt := "请调用工具读取一下当前工作区目录下 hello.txt 文件的内容,并用一句话向我总结它说了什么。" + + err := eng.Run(context.Background(), prompt) + if err != nil { + log.Fatalf("引擎运行崩溃: %v", err) + } +} diff --git a/internal/engine/loop.go b/internal/engine/loop.go index 4185929..7e360b1 100644 --- a/internal/engine/loop.go +++ b/internal/engine/loop.go @@ -29,6 +29,10 @@ func NewAgentEngine(p provider.LLMProvider, r tools.Registry, workDir string, en } } +func (e *AgentEngine) String() string { + return fmt.Sprintf("AgentEngine{workDir: %s, thinking: %v, registry: %s}", e.WorkDir, e.EnableThinking, e.registry) +} + // dumpMessages 打印当前上下文中的所有消息 (调试用) func dumpMessages(msgs []schema.Message) { for i, msg := range msgs { @@ -142,6 +146,12 @@ func (e *AgentEngine) Run(ctx context.Context, userPrompt string) error { result := e.registry.Execute(ctx, toolCall) + status := "✅ 成功" + if result.IsError { + status = "❌ 失败" + } + log.Printf(" -> 📋 ToolCall %s: %s, 结果: %s\n", toolCall.ID, status, result.Output) + if result.IsError { log.Printf(" -> ❌ 工具执行报错: %s\n", result.Output) } else { diff --git a/internal/tools/read_file.go b/internal/tools/read_file.go new file mode 100644 index 0000000..3be7000 --- /dev/null +++ b/internal/tools/read_file.go @@ -0,0 +1,85 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "go-tiny-claw/internal/schema" +) + +// ReadFileTool 实现了读取本地文件内容的工具 +type ReadFileTool struct { + // 将引擎的 WorkDir 注入给工具,限制它只能在此目录及其子目录下操作 + workDir string +} + +func NewReadFileTool(workDir string) *ReadFileTool { + return &ReadFileTool{workDir: workDir} +} + +func (t *ReadFileTool) Name() string { + return "read_file" +} + +// Definition 向大模型清晰地描述这个工具的用途和参数格式 +func (t *ReadFileTool) Definition() schema.ToolDefinition { + return schema.ToolDefinition{ + Name: t.Name(), + Description: "读取指定路径的文件内容。请提供相对工作区的路径。", + // 遵循 JSON Schema 规范定义参数 + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "要读取的文件路径,如 cmd/claw/main.go", + }, + }, + "required": []string{"path"}, + }, + } +} + +// readFileArgs 内部定义用于反序列化的结构体 +type readFileArgs struct { + Path string `json:"path"` +} + +func (t *ReadFileTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + // 1. 延迟解析:将大模型传过来的 JSON 参数解析为强类型结构体 + var input readFileArgs + if err := json.Unmarshal(args, &input); err != nil { + // 返回 error 会被 Registry 捕获并传给大模型,模型会知道自己 JSON 格式写错了 + return "", fmt.Errorf("参数解析失败: %w", err) + } + + // 2. 拼接绝对路径 (注意:生产环境中需要做路径穿越检测防范,防止 ../../etc/passwd) + fullPath := filepath.Join(t.workDir, input.Path) + + // 3. 执行物理 IO 操作 + file, err := os.Open(fullPath) + if err != nil { + return "", fmt.Errorf("打开文件失败: %w", err) + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("读取文件内容失败: %w", err) + } + + // 4. 【核心防线】长度截断保护 + // 为了防止大模型读取几百 MB 的日志文件导致 Context 瞬间爆炸 (OOM), + // 我们在工具内部直接进行物理截断。 + const maxLen = 8000 + if len(content) > maxLen { + truncatedMsg := fmt.Sprintf("%s\n\n...[由于内容过长,已被系统截断至前 %d 字节]...", string(content[:maxLen]), maxLen) + return truncatedMsg, nil + } + + return string(content), nil +} \ No newline at end of file diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 4d5bebd..42f94dd 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -2,14 +2,103 @@ package tools import ( "context" + "encoding/json" + "fmt" + "log" + "go-tiny-claw/internal/schema" ) -// Registry 定义了工具的注册与分发执行接口 +// BaseTool 是所有具体工具必须实现的通用接口 +type BaseTool interface { + // Name 返回工具的全局唯一名称 (大模型通过这个名字调用它) + Name() string + + // Definition 返回用于提交给大模型的工具元信息和参数 JSON Schema + Definition() schema.ToolDefinition + + // Execute 接收大模型吐出的 JSON 参数,执行具体业务逻辑 + // 注意:参数是 json.RawMessage,反序列化由各个具体工具内部自行处理 + Execute(ctx context.Context, args json.RawMessage) (string, error) +} + +// Registry 定义了工具的注册与分发接口 type Registry interface { - // GetAvailableTools 返回当前系统挂载的所有可用工具的 Schema + // Register 挂载一个新的工具到系统中 + Register(tool BaseTool) + + // GetAvailableTools 返回当前系统挂载的所有工具的 Schema,供 Main Loop 交给 Provider GetAvailableTools() []schema.ToolDefinition - // Execute 实际执行模型请求的工具,并返回结果 + // Execute 实际路由并执行模型请求的工具调用 Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult } + +// registryImpl 是 Registry 接口的默认实现 +type registryImpl struct { + // 使用 map 以工具的 Name 作为 Key 进行快速 O(1) 路由查找 + tools map[string]BaseTool +} + +func NewRegistry() Registry { + return ®istryImpl{ + tools: make(map[string]BaseTool), + } +} + +func (r *registryImpl) String() string { + var names []string + for name := range r.tools { + names = append(names, name) + } + return fmt.Sprintf("Registry{tools: %v}", names) +} + +func (r *registryImpl) Register(tool BaseTool) { + name := tool.Name() + if _, exists := r.tools[name]; exists { + log.Printf("[Warning] 工具 '%s' 已经被注册,将被覆盖。\n", name) + } + r.tools[name] = tool + log.Printf("[Registry] 成功挂载工具: %s\n", name) +} + +func (r *registryImpl) GetAvailableTools() []schema.ToolDefinition { + var defs []schema.ToolDefinition + for _, tool := range r.tools { + defs = append(defs, tool.Definition()) + } + return defs +} + +func (r *registryImpl) Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult { + // 1. 路由查找:如果在注册表中找不到该工具,这是模型产生了幻觉,直接向模型抛出错误 + tool, exists := r.tools[call.Name] + if !exists { + errMsg := fmt.Sprintf("Error: 系统中不存在名为 '%s' 的工具。", call.Name) + return schema.ToolResult{ + ToolCallID: call.ID, + Output: errMsg, + IsError: true, // 标记为错误,模型看到后会尝试纠正 + } + } + + // 2. 执行工具逻辑:将原始的 JSON 字节流直接丢给具体工具 + output, err := tool.Execute(ctx, call.Arguments) + + // 3. 封装结果:将执行结果或底层物理错误封装后返回给 Main Loop + if err != nil { + errMsg := fmt.Sprintf("Error executing %s: %v", call.Name, err) + return schema.ToolResult{ + ToolCallID: call.ID, + Output: errMsg, + IsError: true, + } + } + + return schema.ToolResult{ + ToolCallID: call.ID, + Output: output, + IsError: false, + } +} diff --git a/main.go b/main.go index 440e843..d54c075 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "go-tiny-claw/internal/engine" "go-tiny-claw/internal/schema" + "go-tiny-claw/internal/tools" ) // mockProvider 模拟大模型推理 @@ -36,6 +37,8 @@ type mockRegistry struct{} func (m *mockRegistry) GetAvailableTools() []schema.ToolDefinition { return nil } +func (m *mockRegistry) Register(tool tools.BaseTool) {} + func (m *mockRegistry) Execute(ctx context.Context, call schema.ToolCall) schema.ToolResult { return schema.ToolResult{ ToolCallID: call.ID,