From bd41f48971d324849096fb2d329d3e96d8a0c0f8 Mon Sep 17 00:00:00 2001 From: "Ding, Shuo" Date: Mon, 9 Mar 2026 17:38:13 +0800 Subject: [PATCH] feat: support file-aware model switch and update tech docs --- configs/env.sample | 2 + doc/技术说明文档.md | 256 +++++++++++++++++++++----------------- internal/config/config.go | 30 ++++- internal/llm/client.go | 94 +++++++++++--- 4 files changed, 245 insertions(+), 137 deletions(-) diff --git a/configs/env.sample b/configs/env.sample index d08a190..f962301 100644 --- a/configs/env.sample +++ b/configs/env.sample @@ -21,6 +21,8 @@ FEISHU_VERIFY_TOKEN= LLM_BASE_URL=https://api.openai.com/v1 LLM_API_KEY= LLM_MODEL=gpt-4o-mini +LLM_FILE_MODEL=gpt-4o-mini +LLM_FILE_PROMPT_MODE=user_content_file_parts SQLITE_PATH=./data/laodingbot.db ALLOWED_DIRS=./workspace,./data,./skills diff --git a/doc/技术说明文档.md b/doc/技术说明文档.md index 1ebc8cf..e31b918 100644 --- a/doc/技术说明文档.md +++ b/doc/技术说明文档.md @@ -1,73 +1,80 @@ -# LaodingBot 技术说明文档(2026-02-28 最新实现) +# LaodingBot 技术说明文档(2026-03-09) -> 本文档基于当前代码状态,描述真实可运行架构与能力边界。 +> 本文档基于当前代码状态(含本次“文档问答 + 模型切换”改造)整理,描述真实可运行架构、能力边界与配置方式。 --- ## 1. 项目定位 -LaodingBot 当前已从“单进程工具调用 MVP”演进为: -- **父进程 Agent 编排**(技能路由 + ReAct + 记忆) -- **子进程 ToolHost 执行**(JSON-RPC) -- **workspace 隔离运行空间**(配置与工具权限收敛) -- **能力缺口闭环**(落库、聚类、自动生成技能并热加载) +LaodingBot 当前架构为: +- 父进程 Agent 编排(技能路由 + 统一 ReAct + 记忆) +- 子进程 ToolHost 执行(JSON-RPC) +- runtime workspace 隔离(配置、数据、技能、工具权限收敛) +- 能力缺口闭环(落库、聚类、自动生成技能、热加载) +- 文档问答链路(飞书文件下载 -> 上传 LLM -> 缓存 file_id -> 下一轮文本问答使用) -核心目标:让 Agent 在安全边界内持续补全能力,而不是仅做静态问答。 +核心目标:在安全边界内持续扩展能力,并兼容文本问答与文档长上下文问答的混合场景。 --- -## 2. 目录与模块 +## 2. 关键模块 -- `cmd/bot/main.go`:应用入口、workspace 引导、toolhost 启动、通道分发 -- `internal/config/config.go`:配置加载、workspace 路径解析、安全策略归一化 -- `internal/runtimews/bootstrap.go`:运行时 workspace 准备与种子目录复制 -- `internal/agent/orchestrator.go`:主编排器(技能匹配、ReAct、能力缺口闭环) -- `internal/toolhost/*`:工具子进程协议、服务端、客户端、远程工具适配 -- `internal/tools/filetool/filetool.go`:文件工具(`read/list/write`) -- `internal/tools/shelltool/shelltool.go`:命令工具(白名单 + 超时 + 输出限制) -- `internal/memory/store_sqlite.go`:消息与能力缺口存储、聚类查询 -- `internal/knowledge/loader.go`:skill/soul 加载 -- `internal/knowledge/drafts.go`:能力缺口驱动的 skill 自动生成 +- `cmd/bot/main.go`:应用入口、workspace 引导、toolhost 启动、消息通道分发 +- `internal/config/config.go`:配置加载、workspace 解析、安全策略归一化、LLM 模型策略 +- `internal/runtimews/bootstrap.go`:runtime workspace 初始化与种子复制 +- `internal/agent/orchestrator.go`:主编排(路由、ReAct、文件上下文、能力缺口) +- `internal/llm/client.go`:OpenAI 兼容客户端(聊天、文件上传、文件注入模式) +- `internal/transport/feishu/bot.go`:飞书事件接入、文件下载与本地落盘 +- `internal/toolhost/*`:工具子进程协议、客户端/服务端、远程工具适配 +- `internal/memory/store_sqlite.go`:消息与能力缺口存储 +- `internal/knowledge/*`:soul/skills 加载与技能草稿生成 --- -## 3. 启动链路(当前) +## 3. 启动链路 `main()` 执行顺序: -1. 建立可取消上下文(SIGINT/SIGTERM)。 +1. 创建 SIGINT/SIGTERM 可取消上下文。 2. 调用 `runtimews.PrepareFromEnv()`: - 解析 `AGENT_WORKSPACE_DIR`(默认 `./workspace/agent_runtime`) - - 将 `configs/data/skills/bot_context` 种子复制到 runtime workspace(缺失才复制) - - 设定 `CONFIG_ENV_FILE=/configs/env` -3. 调用 `config.Load()`,优先读取 workspace env。 -4. 若 `--toolhost` 模式,进入子进程服务。 -5. 正常父进程:初始化日志、SQLite、ToolHost Client、知识、Orchestrator。 -6. 根据 `MESSAGE_CHANNEL` 启动 Telegram 或 Feishu transport。 + - 复制 `configs/data/skills/bot_context` 种子到 runtime workspace(缺失才复制) + - 注入 `CONFIG_ENV_FILE=/configs/env` +3. 调用 `config.Load()`,按优先级读取 env。 +4. `--toolhost` 模式下进入子进程工具服务。 +5. 父进程初始化日志、SQLite、ToolHost Client、知识、Orchestrator。 +6. 按 `MESSAGE_CHANNEL` 启动 Telegram 或 Feishu。 --- -## 4. 配置加载与优先级(关键变更) +## 4. 配置优先级与关键配置 -`config.Load()` 的 env 读取优先级: +`config.Load()` 的 env 优先级: 1. `CONFIG_ENV_FILE`(强覆盖) -2. `/configs/env` 与 `/.env`(强覆盖) -3. 根目录 `configs/env` 与 `.env`(仅兜底,不覆盖已有值) +2. `/configs/env`、`/.env`(强覆盖) +3. 根目录 `configs/env`、`.env`(仅兜底) -这保证 VS Code Debug 场景下,**workspace 配置优先于根目录配置**。 - -### 关键配置 -- `REACT_MAX_STEPS`:必须来自 env(无代码默认值) -- `AGENT_WORKSPACE_DIR`:agent 运行空间根目录 +关键配置: +- `REACT_MAX_STEPS`:必须配置(1~8) +- `AGENT_WORKSPACE_DIR`:运行空间根目录 - `ALLOWED_DIRS` / `ALLOWED_COMMANDS` / `WORK_DIR`:工具安全边界 -- `AUTO_SKILL_DIR`:自动生成 skill 的目标目录(默认 workspace/skills) -- `GAP_DRAFT_TRIGGER_COUNT` / `GAP_CLUSTER_LOOKBACK_HOURS`:缺口聚类触发参数 +- `AUTO_SKILL_DIR`:自动生成 skill 目录 +- `GAP_DRAFT_TRIGGER_COUNT` / `GAP_CLUSTER_LOOKBACK_HOURS`:缺口聚类参数 + +### 新增(文档问答) +- `LLM_MODEL`:常规文本问答模型(路由 + ReAct 默认模型) +- `LLM_FILE_MODEL`:携带 `file_id` 时使用的模型(未设置时回退 `LLM_MODEL`) +- `LLM_FILE_PROMPT_MODE`:文件注入模式 + - `user_content_file_parts`(默认):`messages.user.content=[{type:text},{type:file,file_id}]` + - `system_fileid_uri`:按 provider 要求注入 `system: fileid://` + +说明:`LLM_FILE_PROMPT_MODE=system_fileid_uri` 可对齐 Qwen/DashScope 兼容模式中常见的 `fileid://` 用法。 --- ## 5. workspace 隔离策略 -当前实现中,Agent 与工具默认都在 workspace 内高权限运行: -- 相对路径统一按 `AGENT_WORKSPACE_DIR` 解析 +当前实现中,Agent 与工具默认都在 workspace 内运行: +- 相对路径按 `AGENT_WORKSPACE_DIR` 解析 - `ALLOWED_DIRS` 强制补齐: - workspace 根 - `workspace/skills` @@ -75,122 +82,147 @@ LaodingBot 当前已从“单进程工具调用 MVP”演进为: - `workspace/workspace` - `ALLOWED_COMMANDS` 自动补齐:`go`、`curl`、`curl.exe` -`filetool` 对相对路径优先按 workspace 根解析,避免写到代码仓库根目录。 +`filetool` 对相对路径优先解析到 workspace 根,避免写到仓库根。 --- ## 6. ToolHost 子进程架构 -当前工具调用已迁移到 JSON-RPC 子进程: -- 协议方法:`ping`、`tool.list`、`tool.call` -- 父进程 `Client` 能力: +工具调用通过 JSON-RPC 子进程完成: +- 协议:`ping`、`tool.list`、`tool.call` +- 父进程客户端能力: - 调用超时 - 心跳检测 - 失败重启与重试 - 并发限制(信号量) -- 子进程 stdout 仅承载协议数据(避免日志污染 RPC) +- 子进程 stdout 仅输出协议内容,避免日志污染 -效果:工具崩溃不会直接拖垮 Agent 主编排逻辑。 +结果:工具崩溃不会直接拖垮 Agent 主编排。 --- -## 7. ReAct 与技能路由 +## 7. 文本问答主流程(统一 ReAct) -`Orchestrator` 流程: -1. 保存用户消息到 SQLite -2. 读取最近对话并压缩 -3. LLM 进行技能路由(最多命中 2 个) -4. 若无技能命中:尝试回退到 `创建skill` 技能 -5. 进入 ReAct 多轮决策(`action/final`) -6. 工具调用观察写入 scratchpad +`Orchestrator.HandleMessage*()` 流程: +1. 保存用户消息 +2. 加载最近消息并压缩 +3. 执行能力路由(Router) +4. 进入统一 ReAct 循环 +5. 按决策调用工具并将 Observation 写入 scratchpad +6. 直到 `is_final_answer=true` 7. 保存 assistant 回复 -工具错误会结构化为: -- `ERROR_CODE=...; TOOL=...; REASON=...` +注:当前循环有固定安全上限 20 步(代码内硬上限)。 --- -## 8. 能力缺口闭环(已落地) +## 8. 文档问答链路(飞书) -当出现“不会做”信号(如无 skill、解析失败、工具失败)时: -1. 写入 `capability_gaps` 表 -2. 进行意图归一化聚类(按 `intent_key + reason`) -3. 高频达到阈值后自动生成 skill 文件 -4. 自动调用 `ReloadSkills()` 热加载 +### 8.1 接收与下载 +`internal/transport/feishu/bot.go` 在 `msg_type=file` 时: +1. 从事件中解析 `file_key`、`file_name` +2. 调用飞书 `message resource` 下载二进制 +3. 校验大小(默认上限 20MB) +4. 保存到本地 `files/` 目录 +5. 组装 `IncomingMessage{FileBytes, FileMime, FilePath}` 交给主流程 -可通过消息命令查看与控制: -- `/capability_gaps`:输出当前高频缺口清单 -- `/reload_skills`:手动热加载 skills +### 8.2 上传与缓存 file_id +`cmd/bot/main.go` 在 Feishu 文件消息分支: +- 调用 `engine.HandleMessageWithFiles(..., text="", files=[...])` +- Orchestrator 识别为 `isFileOnly`: + - 上传文件到 LLM(`UploadFile(..., purpose=file-extract)`) + - 缓存 `pendingFiles[chat_id::user_id]` + - 回复“文件上传完成,等待下一次提问” + +### 8.3 下一轮文本提问使用 file_id +当用户随后发送文本: +- Orchestrator 取出 `pendingFiles` +- 构建 `fileCtx.FileIDs` +- 在 ReAct 内通过 `generateWithOptionalFiles()` 调用 LLM +- 回答成功后清空该用户待消费文件缓存 + +该行为与需求一致:文件上传与提问分离,且 `file_id` 在下一轮问答自动注入。 --- -## 9. 自动生成 skill 的当前行为 +## 9. LLM 文件能力实现细节 -自动生成由 `internal/knowledge/drafts.go` 执行: -- 目标目录:`AUTO_SKILL_DIR`(默认 workspace/skills) -- 命名:`auto_/skill.md` -- 仅在文件不存在时创建,避免重复覆盖 -- 模板内包含:触发背景、执行流程、工具建议、测试建议 +`internal/llm/client.go` 当前能力: -并额外提供基础引导技能: -- `skills/skill_builder/skill.md` +### 9.1 文件上传 +- 接口:`POST /files` +- multipart 字段:`purpose` + `file` +- 目的值尝试顺序:调用方指定 -> `file-extract` -> `batch` +- 兼容返回:`id` 或 `data.id` + +### 9.2 模型切换策略 +- 无文件:使用 `LLM_MODEL` +- 有文件:优先使用 `LLM_FILE_MODEL` + +### 9.3 文件注入策略 +- `user_content_file_parts`:`user.content` 使用 text/file parts +- `system_fileid_uri`:将每个 `file_id` 注入为一条 `system: fileid://` 消息 + +这使同一套 ReAct 编排可适配不同 provider 的文件上下文协议差异。 --- -## 10. file/shell 工具现状 +## 10. 为什么不会破坏 ReAct / tools / skills -### file tool -支持: -- `read ` -- `list ` -- `write \n` +本次改造仅发生在 LLM I/O 层,不改变编排核心: +- ReAct 决策协议(JSON 输出格式)不变 +- ToolHost、工具注册与调用链路不变 +- 技能加载、路由与能力缺口闭环不变 +- 仅在 `GenerateWithFiles()` 分支切换模型与消息格式 -特性: -- 白名单路径检查 -- 目录误读防护:`read` 目录返回 `PATH_IS_DIRECTORY` -- 输出长度限制 - -### shell tool -特性: -- 命令白名单(首 token) -- 超时中断 -- 固定工作目录 -- 输出截断 -- Windows 不可执行命令友好报错 +因此:文本问答仍走原有路径,文档问答只在“带 file_id 的 LLM 调用”处差异化。 --- ## 11. 数据存储 SQLite 表: -1. `messages`:对话消息 +1. `messages`:用户与 assistant 对话 2. `capability_gaps`:能力缺口事件 -提供查询: -- 最近消息 -- 最近缺口事件 -- 高频缺口聚类(含计数与最近出现时间) +支持查询:最近消息、最近缺口、高频缺口聚类。 --- -## 12. 与最初文档相比的变化 +## 12. 与旧文档相比的更新点 -当前代码已经完成并替代旧文档中的以下“待实现项”: -- ToolHost 子进程隔离(已实现) -- 能力缺口闭环(已实现) -- 自动 skill 生成与热加载(已实现) -- workspace 配置优先与运行空间隔离(已实现) - -仍属于持续演进项: -- 新工具代码自动注册与生效的全自动化流水线 -- 更细粒度权限域(按 skill/tool 分级) -- 更强的自动化验收(e2e + 故障注入) +已补充并对齐代码现状: +- 飞书文件事件下载与本地保存流程 +- 文件上传到 LLM 并缓存 `file_id` 的两阶段问答流程 +- 双模型配置:`LLM_MODEL` + `LLM_FILE_MODEL` +- 文件注入模式:`LLM_FILE_PROMPT_MODE` +- “不影响 ReAct/tools/skills”的边界说明 --- -## 13. 下一步建议 +## 13. 推荐配置(Qwen Long 场景) -1. 为 `toolhost client/server` 增加专项故障单测(心跳失败、子进程崩溃、并发压力)。 -2. 增加“自动生成 tool 后自动接线注册”的流水线模块。 -3. 为 skill 自动生成增加结构门禁(frontmatter/章节完整性校验)。 -4. 引入操作审计视图,串联 trace_id 与 capability_gap。 +示例: + +```env +LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +LLM_API_KEY= + +LLM_MODEL=qwen-plus +LLM_FILE_MODEL=qwen-long +LLM_FILE_PROMPT_MODE=system_fileid_uri +``` + +解释: +- 普通对话用 `qwen-plus` +- 文档问答自动切到 `qwen-long` +- 文件上下文注入采用 `fileid://` 形式 + +--- + +## 14. 后续建议 + +1. 为 `internal/llm/client.go` 增加表驱动单测,覆盖两种 `LLM_FILE_PROMPT_MODE`。 +2. 在能力路由中加入“文档问答意图”标记,优化带文件时的提示词压缩策略。 +3. 为 pending file_id 增加 TTL 清理,避免长期未消费堆积。 +4. 增加 e2e 用例:飞书文件消息 -> 下一轮提问 -> 产出稳定答案。 diff --git a/internal/config/config.go b/internal/config/config.go index b2afca0..d5b7a7d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,9 +46,11 @@ type FeishuConfig struct { } type LLMConfig struct { - BaseURL string - APIKey string - Model string + BaseURL string + APIKey string + Model string + FileModel string + FilePromptMode string } type SecurityConfig struct { @@ -94,9 +96,11 @@ func Load() (Config, error) { EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"), }, LLM: LLMConfig{ - BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"), - APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")), - Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"), + BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"), + APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")), + Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"), + FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")), + FilePromptMode: normalizeFilePromptMode(defaultIfEmpty(os.Getenv("LLM_FILE_PROMPT_MODE"), "user_content_file_parts")), }, SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")), WebSearch: WebSearchConfig{ @@ -155,6 +159,9 @@ func Load() (Config, error) { if cfg.LLM.APIKey == "" { return Config{}, fmt.Errorf("LLM_API_KEY is required") } + if cfg.LLM.FilePromptMode != "user_content_file_parts" && cfg.LLM.FilePromptMode != "system_fileid_uri" { + return Config{}, fmt.Errorf("LLM_FILE_PROMPT_MODE must be one of: user_content_file_parts, system_fileid_uri") + } cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir) cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir) @@ -391,3 +398,14 @@ func splitCSV(raw string) []string { } return out } + +func normalizeFilePromptMode(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + if v == "" { + return "user_content_file_parts" + } + if v == "system_fileid" || v == "system_fileid_url" || v == "system_fileid_uri" { + return "system_fileid_uri" + } + return v +} diff --git a/internal/llm/client.go b/internal/llm/client.go index bd435f1..2a1e9f4 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -34,20 +34,24 @@ type InputFile struct { } type OpenAICompatibleClient struct { - baseURL string - apiKey string - model string - http *http.Client - log *logger.Logger + baseURL string + apiKey string + model string + fileModel string + filePromptMode string + http *http.Client + log *logger.Logger } func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient { return &OpenAICompatibleClient{ - baseURL: cfg.BaseURL, - apiKey: cfg.APIKey, - model: cfg.Model, - http: &http.Client{Timeout: 60 * time.Second}, - log: log, + baseURL: cfg.BaseURL, + apiKey: cfg.APIKey, + model: cfg.Model, + fileModel: cfg.FileModel, + filePromptMode: cfg.FilePromptMode, + http: &http.Client{Timeout: 60 * time.Second}, + log: log, } } @@ -107,16 +111,20 @@ func (c *OpenAICompatibleClient) GenerateWithFiles(ctx context.Context, systemPr } func (c *OpenAICompatibleClient) generateInternal(ctx context.Context, systemPrompt, userPrompt string, fileIDs []string) (string, error) { - if c.log != nil { - c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d", c.model, len(systemPrompt), len(userPrompt), len(fileIDs)) + model := c.model + ids := nonEmptyIDs(fileIDs) + if len(ids) > 0 { + if strings.TrimSpace(c.fileModel) != "" { + model = c.fileModel + } } - userContent := buildUserContent(userPrompt, fileIDs) + if c.log != nil { + c.log.Debugf("llm request start model=%s system_len=%d user_len=%d file_count=%d file_prompt_mode=%s", model, len(systemPrompt), len(userPrompt), len(ids), c.normalizedFilePromptMode()) + } + messages := buildMessages(systemPrompt, userPrompt, ids, c.normalizedFilePromptMode()) body := chatRequest{ - Model: c.model, - Messages: []chatMessage{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userContent}, - }, + Model: model, + Messages: messages, } b, err := json.Marshal(body) if err != nil { @@ -179,12 +187,32 @@ func (c *OpenAICompatibleClient) generateInternal(ctx context.Context, systemPro return "", fmt.Errorf("llm returned empty choices") } if c.log != nil { - c.log.Infof("llm response success model=%s output_len=%d", c.model, len(out.Choices[0].Message.Content)) + c.log.Infof("llm response success model=%s output_len=%d", model, len(out.Choices[0].Message.Content)) } return out.Choices[0].Message.Content, nil } +func buildMessages(systemPrompt, userPrompt string, fileIDs []string, mode string) []chatMessage { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "system_fileid_uri" { + msgs := []chatMessage{{Role: "system", Content: systemPrompt}} + for _, id := range fileIDs { + if strings.TrimSpace(id) == "" { + continue + } + msgs = append(msgs, chatMessage{Role: "system", Content: "fileid://" + strings.TrimSpace(id)}) + } + msgs = append(msgs, chatMessage{Role: "user", Content: userPrompt}) + return msgs + } + userContent := buildUserContent(userPrompt, fileIDs) + return []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userContent}, + } +} + func buildUserContent(userPrompt string, fileIDs []string) any { trimmedPrompt := strings.TrimSpace(userPrompt) if len(fileIDs) == 0 { @@ -325,3 +353,31 @@ func appendIfMissing(items []string, value string) []string { } return append(items, value) } + +func nonEmptyIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + seen := map[string]struct{}{} + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (c *OpenAICompatibleClient) normalizedFilePromptMode() string { + mode := strings.ToLower(strings.TrimSpace(c.filePromptMode)) + if mode == "system_fileid" || mode == "system_fileid_url" || mode == "system_fileid_uri" { + return "system_fileid_uri" + } + return "user_content_file_parts" +}