feat: support file-aware model switch and update tech docs

This commit is contained in:
2026-03-09 17:38:13 +08:00
parent 52b8dbb835
commit bd41f48971
4 changed files with 245 additions and 137 deletions

View File

@@ -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

View File

@@ -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=<workspace>/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=<workspace>/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. `<workspace>/configs/env``<workspace>/.env`(强覆盖)
3. 根目录 `configs/env``.env`(仅兜底,不覆盖已有值
2. `<workspace>/configs/env``<workspace>/.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://<id>`
说明:`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_<intent_key>/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://<id>` 消息
这使同一套 ReAct 编排可适配不同 provider 的文件上下文协议差异。
---
## 10. file/shell 工具现状
## 10. 为什么不会破坏 ReAct / tools / skills
### file tool
支持:
- `read <path>`
- `list <path>`
- `write <path>\n<content>`
本次改造仅发生在 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=<your_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 用例:飞书文件消息 -> 下一轮提问 -> 产出稳定答案。

View File

@@ -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
}

View File

@@ -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"
}