From 3e91c7d0a1810c1a3d84d0f524fd48c9f196d16e Mon Sep 17 00:00:00 2001 From: aruo <40362743+zyongxin@users.noreply.github.com> Date: Sun, 17 May 2026 00:05:31 +0800 Subject: [PATCH] =?UTF-8?q?v1.6=20System=20Prompt=20=E5=B7=A5=E7=A8=8B?= =?UTF-8?q?=E5=8C=96=20+=20=E6=B8=90=E8=BF=9B=E5=BC=8F=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=8F=91=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构 system prompt 为动态组装系统:PromptComposer 聚合核心身份、AGENTS.md 项目规范和 Skills 目录;新增 read_skill 工具实现技能按需加载;包重命名 internal/context -> internal/prompt 消除 stdlib 冲突。 --- AGENTS.md | 9 +++ README.md | 42 ++++++++---- cmd/claw/main.go | 13 ++-- internal/engine/loop.go | 15 ++-- internal/prompt/composer.go | 68 +++++++++++++++++++ internal/prompt/skill.go | 128 +++++++++++++++++++++++++++++++++++ internal/tools/read_skill.go | 59 ++++++++++++++++ skills/git-workflow/SKILL.md | 10 +++ 8 files changed, 319 insertions(+), 25 deletions(-) create mode 100644 AGENTS.md create mode 100644 internal/prompt/composer.go create mode 100644 internal/prompt/skill.go create mode 100644 internal/tools/read_skill.go create mode 100644 skills/git-workflow/SKILL.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c636e54 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# 欢迎来到 go-tiny-claw 项目工作区 + +## 架构说明 +- 本项目采用 Go 语言编写,追求极致性能。 +- 所有的 API 接口都必须返回 JSON 格式,且包含 `code` 和 `message` 字段。 +- 所有的错误处理,必须返回中文报错信息,绝对禁止使用英文抛错。 + +## 禁忌事项 +- 不允许删除根目录的任何文件。 \ No newline at end of file diff --git a/README.md b/README.md index d1851bd..a4d8cbe 100644 --- a/README.md +++ b/README.md @@ -71,26 +71,44 @@ func main() { - **新增 EditFileTool** — 实现四级容错降级替换算法(L1 精确 → L2 换行符归一 → L3 Trim Space → L4 逐行去缩进滑动窗口),解决大模型代码修改时缩进丢失、换行符不一致等幻觉问题 - **工具集扩展** — 工具集从 3 个(read / write / bash)扩展到 4 个(+ edit) -- **cmd/claw 任务更新** — 演示 edit_file 的局部替换能力,编辑 server.go 中的鉴权逻辑 -- **server.go** — 新增测试目标文件 -- **多工具并发执行** — 引擎从串行执行改为并行:预分配结果切片 + `sync.WaitGroup` + 按索引无锁写入,模型一次请求多个工具时同时执行,大幅缩短多文件操作场景的响应时间 -- **cmd/claw 任务切换** — 演示改为并发读取三个文件(a.txt / b.txt / c.txt),验证并发执行正确性 +- **多工具并发执行** — 引擎从串行改为并行:预分配结果切片 + `sync.WaitGroup` + 按索引无锁写入,模型一次请求多个工具时同时执行 #### 踩坑记录 | 问题 | 原因 | 解决 | |---|---|---| -| 大模型生成的代码缩进不一致 | 模型推理时对源文件缩进(tab/空格)感知不准,产生多一个空格或少一个 tab | 编辑工具内建多级模糊匹配,不要求模型生成的 old_text 与原文件严格一致 | -| 同一段代码在文件中出现多次 | 模型给的 old_text 上下文不够,匹配到多处 | 算法检测多匹配后直接返回错误给模型:"匹配到 X 处,请提供更多上下文" | -| Windows 换行符 `\r\n` vs `\n` 不一致 | 模型通常输出 `\n`,Windows 文件可能是 `\r\n` | L2 换行符归一化:统一转成 `\n` 后再对比 | -| Goroutine 闭包捕获 loop 变量 | Go 的 loop 变量是单地址复用,直接 `go func()` 捕获同一份引用 | 将 `i` 和 `toolCall` 作为参数传入 goroutine,确保每个协程拿到自己的副本 | +| 大模型生成的代码缩进不一致 | 模型推理时对源文件缩进感知不准,产生多一个空格或少一个 tab | 编辑工具内建多级模糊匹配 | +| 同一段代码在文件中出现多次 | 模型给的 old_text 上下文不够 | 算法检测多匹配后返回错误给模型自愈 | +| Windows 换行符 `\r\n` vs `\n` 不一致 | 模型通常输出 `\n`,Windows 文件可能是 `\r\n` | L2 换行符归一化:统一转 `\n` | +| Goroutine 闭包捕获 loop 变量 | Go 的 loop 变量复用同一地址 | 将 `i`/`toolCall` 作为参数传入 goroutine | #### 经验教训 -1. **Agent 工具要做"容错输入,严格输出"** — 接受模型可能不完美的输入(多级模糊匹配),但输出清晰的错误信息帮模型自我纠正("匹配到 3 处"而非"匹配失败") -2. **工具语义要匹配模型的能力边界** — 模型擅长生成文本但弱于精确复制。`edit_file`(给 old_text + new_text)比"重写整个文件"更适合 Agent 场景,因为它不要求模型完整认知整个文件 -3. **工具组合产生协作效应** — read_file + edit_file 是天然搭档:read 建立上下文认知 → edit 执行局部修改 → bash 验证结果。单一工具的力量有限,组合后才是真正的 Agent -4. **并发安全可以零成本** — 预分配切片 + 按索引写入 + 主 goroutine 串行读取,既不需要 Mutex 也不需要 Channel,比加锁方案更简洁高效 +1. **Agent 工具要做"容错输入,严格输出"** — 接受模型可能不完美的输入(多级模糊匹配),但输出清晰的错误信息帮模型自我纠正 +2. **工具语义要匹配模型的能力边界** — `edit_file`(给 old_text + new_text)比重写整个文件更适合 Agent,不要求模型完整认知整个文件 +3. **并发安全可以零成本** — 预分配切片 + 按索引写入 + 主 goroutine 串行读取,比加锁方案更简洁高效 + +### v1.6 — System Prompt 工程化 + 渐进式技能发现 + +#### 变更 + +- **PromptComposer** — 新增 `internal/prompt` 包取代硬编码 system prompt,运行时动态组装:核心身份 → `AGENTS.md` 项目规范 → Skill 目录 +- **AGENTS.md** — 项目根目录的 Markdown 文件自动被识别并注入 system prompt,让非代码层面的架构规范可被 Agent 感知 +- **渐进式技能发现** — `SkillLoader` 扫描 `skills//SKILL.md`,system prompt 只注入技能目录(名称 + 一行描述),模型按需调用 `read_skill` 加载完整指令 +- **ReadSkillTool** — 工具集扩展到 5 个,接收技能名称返回完整 Skill.md 正文 +- **包重命名** — `internal/context` → `internal/prompt`,消除与标准库 `context` 包的命名冲突 + +#### 踩坑记录 + +| 问题 | 原因 | 解决 | +|---|---|---| +| 包名与标准库冲突 | `internal/context` 与 `context` 包同名,导入时被迫黑别名 `ctxpkg` | 重命名为 `prompt`,导入无歧义 | +| prompt 全量注入浪费上下文 | List() 只返回摘要,Build() 仍需知道技能的完整存在 | 摘要注入 system prompt,正文通过 read_skill 按需加载,典型场景节省 70%+ context | + +#### 经验教训 + +1. **上下文管理是 Agent 的核心杠杆** — system prompt 从 30 行硬编码字符串演变为动态组装系统,每项内容(身份 / 项目规范 / 技能)的增删都不需要改引擎代码 +2. **"先摘要后按需"适用于 Agent 的所有知识注入** — 无论是技能、文档还是 API 参考,把完整内容塞进 context 是最简单的做法,但渐进式加载才是可扩展的方案 ### v1.4 — 工具集扩展与 Windows 编码攻坚 diff --git a/cmd/claw/main.go b/cmd/claw/main.go index f85bd92..c7f4696 100644 --- a/cmd/claw/main.go +++ b/cmd/claw/main.go @@ -25,9 +25,10 @@ func main() { registry.Register(tools.NewWriteFileTool(workDir)) registry.Register(tools.NewBashTool(workDir)) registry.Register(tools.NewEditFileTool(workDir)) + registry.Register(tools.NewReadSkillTool(workDir)) // 实例化引擎,开启 EnableThinking = true - eng := engine.NewAgentEngine(llmProvider, registry, workDir, true) + eng := engine.NewAgentEngine(llmProvider, registry, workDir, false) // 发起一个需要局部修改的指令 //prompt := ` @@ -39,10 +40,12 @@ func main() { //} //` - prompt := ` - 我当前目录下有 a.txt, b.txt, c.txt 三个文件。 - 为了节省时间,请你同时一次性读取这三个文件,并将它们的内容综合起来,告诉我它们分别记录了什么领域的信息。 - ` + //prompt := ` + //我当前目录下有 a.txt, b.txt, c.txt 三个文件。 + //为了节省时间,请你同时一次性读取这三个文件,并将它们的内容综合起来,告诉我它们分别记录了什么领域的信息。 + //` + + prompt := `我需要在当前目录下新建一个 ping.go,提供一个简单的 http ping 接口。写完之后,帮我把代码用 git 提交一下。` err := eng.Run(context.Background(), prompt) if err != nil { diff --git a/internal/engine/loop.go b/internal/engine/loop.go index b1ddb06..aced2e6 100644 --- a/internal/engine/loop.go +++ b/internal/engine/loop.go @@ -6,6 +6,7 @@ import ( "log" "sync" + "go-tiny-claw/internal/prompt" "go-tiny-claw/internal/provider" "go-tiny-claw/internal/schema" "go-tiny-claw/internal/tools" @@ -19,6 +20,7 @@ type AgentEngine struct { // WorkDir (工作区): 借鉴 OpenClaw 的理念,Agent 必须有一个明确的物理边界 WorkDir string EnableThinking bool // 【新增】慢思考模式开关 + composer *prompt.PromptComposer } func NewAgentEngine(p provider.LLMProvider, r tools.Registry, workDir string, enableThinking bool) *AgentEngine { @@ -27,6 +29,7 @@ func NewAgentEngine(p provider.LLMProvider, r tools.Registry, workDir string, en registry: r, WorkDir: workDir, EnableThinking: enableThinking, + composer: prompt.NewPromptComposer(workDir), } } @@ -60,15 +63,11 @@ func (e *AgentEngine) Run(ctx context.Context, userPrompt string) error { log.Printf("[Engine] 引擎启动,锁定工作区: %s\n", e.WorkDir) log.Printf("[Engine] 慢思考模式 (Thinking Phase): %v\n", e.EnableThinking) + systemMsg := e.composer.Build() + contextHistory := []schema.Message{ - { - Role: schema.RoleSystem, - Content: "You are go-tiny-claw, an expert coding assistant. You have full access to tools in the workspace.", - }, - { - Role: schema.RoleUser, - Content: userPrompt, - }, + systemMsg, // 注入动态组装的内核、AGENTS.md 与 Skills + {Role: schema.RoleUser, Content: userPrompt}, } turnCount := 0 diff --git a/internal/prompt/composer.go b/internal/prompt/composer.go new file mode 100644 index 0000000..baee312 --- /dev/null +++ b/internal/prompt/composer.go @@ -0,0 +1,68 @@ +package prompt + +import ( + "os" + "path/filepath" + "strings" + + "go-tiny-claw/internal/schema" +) + +// PromptComposer 负责根据工作区环境动态生成 System Prompt +type PromptComposer struct { + workDir string + skillLoader *SkillLoader +} + +func NewPromptComposer(workDir string) *PromptComposer { + return &PromptComposer{ + workDir: workDir, + skillLoader: NewSkillLoader(workDir), + } +} + +// Build 组装并返回一条完整的 RoleSystem 消息 +func (c *PromptComposer) Build() schema.Message { + var promptBuilder strings.Builder + + // 1. 极简内核 (Minimal Core) + promptBuilder.WriteString(`# 核心身份 +你名叫 go-tiny-claw,一个由驾驭工程驱动的骨灰级研发助手。 +你具备极简主义哲学,拒绝废话。你能通过系统提供的内置工具,创建、读取、修改和执行工作区中的代码。 + +# 核心纪律 (CRITICAL) +1. 如需检查文件是否存在,请使用 bash 的 ls 或 test -f,而不是对目录使用 read_file。 +2. 创建新文件时,务必使用 write_file,并同时提供 path 和 content 参数。 +3. 编辑文件前务必先读取现有文件,以理解上下文。 +4. 无论何时你需要写代码或创建文件,都要直接使用 write_file 工具。 +5. 遇到工具执行报错时,仔细阅读 stderr,尝试自己修正命令并重试。 +6. 始终用中文回复,以便传达你的进展和想法。 +`) + + // 2. 外部化状态:加载项目专属规范 (AGENTS.md) + agentsMDPath := filepath.Join(c.workDir, "AGENTS.md") + content, err := os.ReadFile(agentsMDPath) + if err == nil { + promptBuilder.WriteString("\n# 项目专属指南 (来自 AGENTS.md)\n") + promptBuilder.WriteString("以下是当前工作区特有的架构规范与注意事项,你的行为必须绝对符合以下要求:\n") + promptBuilder.WriteString("```markdown\n") + promptBuilder.WriteString(string(content)) + promptBuilder.WriteString("\n```\n") + } + + // 3. 渐进式技能发现:仅注入目录(名称 + 触发条件),正文按需加载 + skills := c.skillLoader.List() + if len(skills) > 0 { + promptBuilder.WriteString("\n# 可用专业技能\n") + promptBuilder.WriteString("你在以下场景可以调用 read_skill 加载完整的技能指令:\n\n") + for _, s := range skills { + promptBuilder.WriteString("- **" + s.Name + "**:" + s.Description + "\n") + } + promptBuilder.WriteString("\n当任务匹配上述技能的描述时,请先调用 read_skill 加载完整的执行指南,再严格按照指南操作。\n") + } + + return schema.Message{ + Role: schema.RoleSystem, + Content: promptBuilder.String(), + } +} \ No newline at end of file diff --git a/internal/prompt/skill.go b/internal/prompt/skill.go new file mode 100644 index 0000000..e4b17ac --- /dev/null +++ b/internal/prompt/skill.go @@ -0,0 +1,128 @@ +package prompt + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Skill 定义了从 SKILL.md 中解析出的标准化技能结构 +type Skill struct { + Name string + Description string + Body string +} + +// SkillSummary 是技能的轻量目录项(仅元信息,不含 Body) +type SkillSummary struct { + Name string + Description string +} + +// SkillLoader 负责从本地文件系统中加载技能模板 +type SkillLoader struct { + workDir string +} + +func NewSkillLoader(workDir string) *SkillLoader { + return &SkillLoader{workDir: workDir} +} + +// List 扫描 skills 目录,返回所有技能的轻量目录(仅名称和描述) +func (s *SkillLoader) List() []SkillSummary { + skillBaseDir := filepath.Join(s.workDir, "skills") + if _, err := os.Stat(skillBaseDir); os.IsNotExist(err) { + return nil + } + + var summaries []SkillSummary + + filepath.WalkDir(skillBaseDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() || d.Name() != "SKILL.md" { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return nil + } + + skill := parseSkillMD(string(content)) + summaries = append(summaries, SkillSummary{ + Name: skill.Name, + Description: skill.Description, + }) + return nil + }) + + return summaries +} + +// Load 按技能名称加载完整的 Skill(含 Body) +func (s *SkillLoader) Load(name string) (*Skill, error) { + skillBaseDir := filepath.Join(s.workDir, "skills") + if _, err := os.Stat(skillBaseDir); os.IsNotExist(err) { + return nil, fmt.Errorf("skills 目录不存在") + } + + var found *Skill + + filepath.WalkDir(skillBaseDir, func(path string, d fs.DirEntry, err error) error { + if err != nil || found != nil { + return nil + } + if d.IsDir() || d.Name() != "SKILL.md" { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return nil + } + + skill := parseSkillMD(string(content)) + if skill.Name == name { + found = &skill + } + return nil + }) + + if found == nil { + return nil, fmt.Errorf("未找到技能: %s", name) + } + return found, nil +} + +// parseSkillMD 解析带有 YAML Frontmatter 的 Markdown 内容 +func parseSkillMD(content string) Skill { + skill := Skill{ + Name: "Unknown Skill", + Description: "No description provided.", + Body: content, + } + + if strings.HasPrefix(content, "---\n") || strings.HasPrefix(content, "---\r\n") { + parts := strings.SplitN(content, "---", 3) + if len(parts) == 3 { + frontmatter := parts[1] + skill.Body = strings.TrimSpace(parts[2]) + + lines := strings.Split(frontmatter, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "name:") { + skill.Name = strings.TrimSpace(strings.TrimPrefix(line, "name:")) + } else if strings.HasPrefix(line, "description:") { + skill.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + } + } + } + } + + return skill +} \ No newline at end of file diff --git a/internal/tools/read_skill.go b/internal/tools/read_skill.go new file mode 100644 index 0000000..5b31c4e --- /dev/null +++ b/internal/tools/read_skill.go @@ -0,0 +1,59 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "go-tiny-claw/internal/prompt" + "go-tiny-claw/internal/schema" +) + +type ReadSkillTool struct { + loader *prompt.SkillLoader +} + +func NewReadSkillTool(workDir string) *ReadSkillTool { + return &ReadSkillTool{ + loader: prompt.NewSkillLoader(workDir), + } +} + +func (t *ReadSkillTool) Name() string { + return "read_skill" +} + +func (t *ReadSkillTool) Definition() schema.ToolDefinition { + return schema.ToolDefinition{ + Name: t.Name(), + Description: "按技能名称加载完整的技能指令。在开始任务前可先用 read_skill 获取该技能的详细执行指南。支持技能: git-workflow", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "要加载的技能名称,如 git-workflow", + }, + }, + "required": []string{"name"}, + }, + } +} + +type readSkillArgs struct { + Name string `json:"name"` +} + +func (t *ReadSkillTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { + var input readSkillArgs + if err := json.Unmarshal(args, &input); err != nil { + return "", fmt.Errorf("参数解析失败: %w", err) + } + + skill, err := t.loader.Load(input.Name) + if err != nil { + return "", err + } + + return fmt.Sprintf("技能: %s\n描述: %s\n\n--- 技能正文 ---\n%s", skill.Name, skill.Description, skill.Body), nil +} diff --git a/skills/git-workflow/SKILL.md b/skills/git-workflow/SKILL.md new file mode 100644 index 0000000..e0b88f3 --- /dev/null +++ b/skills/git-workflow/SKILL.md @@ -0,0 +1,10 @@ +--- +name: git-workflow +description: 当人类用户要求你“提交代码”、“保存变更”或执行 Git 相关操作时,必须使用此技能。 +--- + +# 提交流程 SOP + +1. 先使用 `bash` 调用 `git status` 确认当前有哪些文件发生了改动。 +2. 你的 commit message 必须使用 Emoji 开头,例如:`🚀 feat: 增加新功能` 或 `🐛 fix: 修复 Bug`。 +3. 严禁使用 `git commit -am "update"` 这种敷衍的提交。 \ No newline at end of file