# 框架工程审查报告 > 审查日期:2026-04-06 > 审查范围:`/home/admin/ccm/engine/` + `/home/admin/ccm/platform/` > 扫描文件数:213 个非测试 .go 文件(含测试共 362 个) --- ## 执行摘要 整体架构设计水平较高,接口化,可组合的思路贯穿全局,明显优于早期方案 的强耦合设计.但有 **3 个结构性问题**会在"成为真正框架"的路上形成硬障碍: 1. **`pkg/context` 包存在进程级全局可变状态**(`contextWindowProvider` / `compactModelProvider`),同进程创建两个 `Engine` 实例时互相覆盖--这是 Multi-tenant SaaS 的致命伤. 2. **`pkg/permission/checker.go` 的工具名字符串硬编码**分散在三处独立 map/switch 中,第三方新增工具类型必须改权限系统核心代码. 3. **`pkg/context/compact.go` 内嵌了一个独立的 HTTP 客户端**,绕开了 `api.Client` 的所有能力(重试,StreamGuard,ErrorClassifier,BearerAuth 可配置)--换供应商时这里会静默失效. 上述问题不影响单进程单租户 CLI 场景,但任何一个进入 SDK 嵌入模式或多租户服务器模式都会暴露. --- ## P0 - 妨碍框架目标(必须修) ### P0-1:`pkg/context` 包的进程级全局可变状态 ✅ 已修复 - **位置**:`pkg/context/compact.go` - ~~**现状**~~:已修复.`NewCompressor(threshold, provider flyto.ModelProvider)` 替代原 `NewCompressor(threshold, apiKey, baseURL, bearerAuth)` 签名. - `contextWindowFn` / `compactModelFn` 实例字段取代全局变量(`SetContextWindowFn` / `SetCompactModelFn` 注入) - `modelContextWindows` 硬编码表已删除,改由 `ModelRegistry` 通过 `SetContextWindowFn` 动态提供 - `NewCompressor` 直接接受 `flyto.ModelProvider`,摘要生成走 `generateSummaryViaProvider` 路径,不再直连 Anthropic HTTP - 全局 `contextWindowProvider` / `compactModelProvider` 保留为兜底(单 Engine 场景向后兼容),但新代码应使用实例级注入 - **修复时间**:2026-04-07 --- ### P0-2:`pkg/permission/checker.go` 工具名字符串硬编码三处 - **位置**:`pkg/permission/checker.go:26-34`,`:68-76`,`:466-471`;`pkg/permission/permission.go:378-384` - **现状**: ```go var fileTools = map[string]bool{ "Edit": true, "Write": true, "Read": true, "FileEdit": true, "FileWrite": true, "FileRead": true, "NotebookEdit": true, } // 以及 var readOnlyGenericTools = map[string]bool{ "Glob": true, "Grep": true, "WebSearch": true, "TaskList": true, } // 以及 switch 硬编码: case toolName == "Bash": case fileTools[toolName]: case toolName == "WebFetch": // 以及 func isEditTool(name string) bool { switch name { case "Edit", "Write", "NotebookEdit": ``` - **影响**:第三方注入一个新的文件操作工具(如 `DatabaseWrite`)时,权限系统会走 `checkGenericPermission` 而非 `checkFilePermission`,路径权限检查和危险路径检测被完全绕过.注入一个新的命令执行工具(如 `PowerShell`)时,同样无法得到 Bash 风格的子命令分解安全分析.这是工具体系的安全漏洞,同时也是扩展障碍. - **建议**:在 `tools.Metadata` 中增加声明字段,让工具自描述自己的分类属性: ```go type Metadata struct { // 已有字段... PermissionClass PermissionClass // "bash" / "file" / "webfetch" / "generic" InputFieldPath string // 文件操作类工具的路径字段名,默认 "file_path" InputFieldURL string // Web 工具的 URL 字段名,默认 "url" } type PermissionClass string const ( PermClassBash PermissionClass = "bash" PermClassFile PermissionClass = "file" PermClassWebFetch PermissionClass = "webfetch" PermClassGeneric PermissionClass = "generic" ) ``` `CheckToolPermission` 改为查询工具注册表获取 `PermissionClass`,而非内嵌字符串 switch.内置工具的三个硬编码 map 变为向后兼容的默认值,可被工具自身声明覆盖. --- ### P0-3:`pkg/context/compact.go` 内嵌独立 HTTP 客户端,绕开 `api.Client` ✅ 已修复 - **位置**:`pkg/context/compact.go` - ~~**现状**~~:已修复.压缩器现在通过 `flyto.ModelProvider` 接口(`generateSummaryViaProvider`)调用摘要生成,完全绕开 Anthropic 专有 HTTP 客户端. - `NewCompressor(threshold, provider)` - provider 路径走 Provider.Stream,不再持有 apiKey/baseURL - `DoCompact` 现在调用 `CompactTiered`(三层降级),而非旧的 `Compact(msgs, apiKey, baseURL)` - `generateSummary`(HTTP 直连路径)保留以供单独使用,但 `Compressor` 不再调用它 - `anthropic-version` 常量 (`AnthropicAPIVersion`) 已提取为 `pkg/context` 包导出常量 - **修复时间**:2026-04-07 --- ## P1 - 工程规范(应该修) ### P1-1:`permission.Engine` 接口的 `SetMode` 并发不安全 - **位置**:`pkg/permission/permission.go:365` - **现状**: ```go func (e *engine) SetMode(m Mode) { e.mode = m } func (e *engine) Mode() Mode { return e.mode } ``` `engine.mode` 字段的读写没有任何同步保护.`plan.go:254` 和 `:307` 在 runLoop goroutine 中调用 `SetMode`,而其他 goroutine(SubAgent,Hook executor)可能同时读 `e.mode`. - **影响**:多 goroutine 并发时数据竞争,`go test -race` 会命中.Plan 模式进入/退出时权限状态可能被并发读者看到中间状态. - **建议**:将 `mode` 改为 `atomic.Value` 或加读写锁: ```go type engine struct { mode atomic.Value // stores Mode // ... } func (e *engine) Mode() Mode { return e.mode.Load().(Mode) } func (e *engine) SetMode(m Mode) { e.mode.Store(m) } ``` --- ### P1-2:`pkg/tools/deferred.go` 核心工具名白名单硬编码 - **位置**:`pkg/tools/deferred.go:27-36` - **现状**: ```go var alwaysLoadTools = map[string]bool{ "Bash": true, "Read": true, "Edit": true, "Write": true, "Glob": true, "Grep": true, "Agent": true, "ToolSearch": true, } ``` 这是包级变量,不可按 `Engine` 实例定制. - **影响**:第三方 import 后若用自定义工具替代 `Bash`(如 `PowerShell`),必须 fork 改源码.不同租户可能需要不同的"核心工具"集合(仓储场景的核心工具是 `ScanBarcode` 而非 `Bash`). - **建议**:`NewDeferredRegistry` 增加 `WithAlwaysLoad(names ...string)` 选项,允许覆盖;同时保留包级变量作为默认值向后兼容.`Engine.New()` 时通过 `cfg.CoreTools` 注入. --- ### P1-3:`internal/tokenizer/tokenizer.go` 模型定价表与 `pkg/config/models.go` 重复 - **位置**:`internal/tokenizer/tokenizer.go:30-77`;`pkg/config/models.go:78-187` - **现状**:两处各维护一份完整的模型定价/上下文窗口 map,字段不完全一致(tokenizer 无 cache 定价,config 有). - **影响**:新增或修改模型时必须改两处,极易漏改.已经出现分歧:`tokenizer.go` 没有 cache 相关定价字段. - **建议**:`internal/tokenizer` 改为通过回调函数获取模型信息(类似 `contextWindowProvider` 的模式,但实例级注入),消除重复.或直接让 `tokenizer` 的公开函数接收 `*config.ModelRegistry` 参数. --- ### P1-4:`pkg/engine/engine.go` 中 `os.Setenv` 修改进程全局状态 - **位置**:`pkg/engine/engine.go:856`,`:946` - **现状**: ```go _ = os.Setenv("FLYTO_SESSION_SOCK", srv.SockPath()) _ = os.Setenv("FLYTO_PLAN_SOCK", pcs.SockPath()) ``` 注释已承认这是 `历史包袱(LEGACY)`. - **影响**:同进程创建第二个 `Engine` 实例时,`FLYTO_SESSION_SOCK` 被覆盖.第一个 Engine 的工具子进程(`Bash` 的 `exec.Cmd`)会错误地连接到第二个 Engine 的 socket,进度消息路由混乱.对于 SDK 嵌入模式(多租户服务器),这是静默的跨租户数据污染. - **建议**:在 `Bash` 工具的 `exec.Cmd.Env` 中按实例注入,而非 `os.Setenv`.`BashTool` 构造时接收 `sockPath string` 参数,`registerBuiltinTools` 传入: ```go // 不污染全局环境 cmd.Env = append(os.Environ(), "FLYTO_SESSION_SOCK="+e.sockPath, ) ``` --- ### P1-5:`pkg/permission/classifier_factory.go` 的 `classifierFactories` 为包级全局 map - **位置**:`pkg/permission/classifier_factory.go:23-28` - **现状**: ```go var classifierFactories = map[string]ClassifierFactory{ "anthropic": NewAnthropicClassifier, // ... } ``` `RegisterClassifierFactory` 直接写这个全局 map,无并发保护. - **影响**:多 goroutine 并发注册分类器工厂(插件热加载场景)会产生 data race.此外作为全局状态,两个 Engine 实例无法各自持有不同的分类器工厂集合. - **建议**:加读写锁;或将工厂表移入 `SecurityClassifier` 的构造参数(`HybridClassifier` 构造时传入已选定的工厂),消除全局状态. --- ### P1-6:`NewOpenAIClassifier` / `NewGoogleClassifier` 是空占位实现 - **位置**:`pkg/permission/classifier_factory.go:104-129` - **现状**: ```go // 历史包袱(LEGACY): OpenAI 的 API 格式与 Anthropic 不同, // 当前通过同一个 api.Client 处理可能不完全兼容。 func NewOpenAIClassifier(client *api.Client, ...) SecurityClassifier { return NewAIClassifier(client, stage1Model, stage2Model) } ``` OpenAI,Google 分类器实际上和 Anthropic 的完全一样,注释明确说明"未来需要专用适配层". - **影响**:工厂模式的存在给了第三方"切换供应商只需设置 provider 字段"的预期,但实际调用 OpenAI 模型时 `api.Client` 的 SSE 解析格式可能不匹配,报解析错误.这是一个会静默工作但结果错误的假兼容. - **建议**:在文档和接口注释中明确标注"OpenAI/Google 为占位实现,当前仅 Anthropic 完整支持";或在 `NewClassifierForProvider` 中对非 `anthropic` provider 返回 `nil` 并记录警告,让调用方感知到未实现. --- ## P2 - 可选优化 ### P2-1:`Engine.Close()` 中的裸 `time.Sleep(100ms)` 不可靠 - **位置**:`pkg/engine/engine.go:1158-1159` - **现状**: ```go // 3. 短暂等待——给 goroutine 一点时间干净退出 time.Sleep(100 * time.Milliseconds) ``` 注释承认不用 `WaitGroup`. - **影响**:高负载环境(CPU 饱和)下 100ms 不够 goroutine 完成;空闲环境下浪费 100ms.不是框架用户期望的优雅关闭语义. - **建议**:对生命周期明确的 goroutine(如 `inboxForwardLoop`)添加轻量 `WaitGroup`,Close 等待它们退出.对不可追踪的 goroutine(如 `BufferedObserver.flushLoop`)保持现有的 Close 接口依赖. --- ### P2-2:`mergeSettings` 覆盖语义对布尔零值不正确 - **位置**:`pkg/config/config.go:348-350` - **现状**: ```go if src.Verbose { dst.Verbose = true } ``` `Verbose: false` 永远无法通过配置文件覆盖 `Verbose: true`(低优先级默认 true → 高优先级 false 无效). - **影响**:如果 CLI 参数设为 `--verbose`(true),用户在项目级 `settings.json` 写 `"verbose": false` 无法关掉,只能移除 CLI 参数. - **建议**:使用指针 `*bool` 表示三值语义(nil=未设置,true,false),或将设置改为 `map[string]interface{}` + deep merge. --- ### P2-3:`ResultStore` 目录硬编码到用户 home - **位置**:`pkg/engine/engine.go:656-657` - **现状**: ```go homeDir, _ := os.UserHomeDir() resultStoreDir := filepath.Join(homeDir, ".flyto", "tool-results") ``` - **影响**:容器化部署(home 目录为 root),只读文件系统,测试环境中无法自定义存储位置. - **建议**:在 `Config` 中添加 `ResultStoreDir string` 字段,空值时使用当前默认路径. --- ### P2-4:`pkg/context/compact.go` 内嵌模型查找表与 `ModelRegistry` 冗余 - **位置**:`pkg/context/compact.go:68-78` - **现状**: ```go var modelContextWindows = map[string]int{ "claude-sonnet-4-6": 200000, // ... } ``` 注释标注为 `历史包袱(LEGACY)`,且 `defaultContextWindowLookup` 作为 `contextWindowProvider` 的默认值,`engine.New()` 调用 `SetContextWindowProvider` 时才会被替换. - **影响**:如果调用方绕过 `engine.New()`,直接使用 `agentctx.Compressor`(合法的 SDK 用法),会用到这个不完整的查找表,新模型返回错误的 200000 兜底值. - **建议**:实现 P0-1 的 `Compressor` 实例级注入后,这个全局查找表可以删除. --- ### P2-5:`CompositeHandler.Handle` 中观察者 handler 错误静默丢弃 - **位置**:`pkg/permission/composite_handler.go:123-133` - **现状**: ```go resp, err := h.Handler(ctx, req) if h.IsDecisionMaker && decision == nil && err == nil && resp != nil { decision = resp } // 观察者的 error 不中断流程 ``` 观察者 handler 的错误被完全丢弃,无法感知审计日志写入失败. - **影响**:合规场景下审计日志写入失败(磁盘满,网络断)被静默忽略,违反合规要求. - **建议**:通过 Observer 接口记录观察者错误: ```go if !h.IsDecisionMaker && err != nil { // 通过注入的 observer 记录,而非中断流程 ch.observer.Error(err, map[string]interface{}{"handler": h.Name}) } ``` 或为 `CompositeHandler` 提供 `OnObserverError func(name string, err error)` 回调. --- ## 意外发现 ### 惊喜发现:`memory/types_registry.go` 的 `init()` 函数注入了大量英文提示词 - **位置**:`pkg/memory/types_registry.go:318-410` - **现状**:`init()` 函数直接将包含英文 Prompt 模板的 `DefaultTypeRegistry` 注入为全局变量,包含多段精心设计的英文提示词(直接影响模型行为). - **影响(框架视角)**:这是框架代码中最"隐形"的 API--第三方导入时 `init()` 自动执行,模型会收到一套编程场景专属的记忆指导.非编程场景(仓储,医疗)的模型行为会被这套提示词污染,除非第三方知道替换 `DefaultTypeRegistry`,否则无法感知. - **建议**:在 `pkg/memory` 的包文档或 `README` 中明确标注 `DefaultTypeRegistry` 是"编程场景默认值",并提供"如何替换为自定义 Registry"的示例.中期可考虑将默认注册从 `init()` 改为 `lazy-init`(首次访问时初始化),允许框架用户在 `main` 中提前注入自定义 Registry. ### 已知问题确认:`api.Client` 每次 `runLoop` 重建 - **位置**:`pkg/engine/engine.go:1884` - **现状**: ```go client := api.NewClient(e.cfg.APIKey, e.cfg.BaseURL, clientOpts...) ``` 每次 `Run` 调用都创建新的 `*http.Client`(内含新的连接池). - **影响**:高频调用场景下 TCP 连接无法复用,延迟和资源消耗显著增加.`Engine` 字段已经持有了所有配置,这里应该复用同一个 `api.Client` 实例(或至少共享 `http.Client`). - **注意**:这不在已知线索清单内,但在频繁调用的 SDK 场景下会是明显的性能问题. --- ## 统计 | 类别 | 数量 | |------|------| | 扫描文件数(非测试 .go) | 213 | | 扫描文件数(含测试) | 362 | | P0 - 妨碍框架目标 | **3 条** | | P1 - 工程规范 | **6 条** | | P2 - 可选优化 | **5 条** | | 意外发现 | **2 条** | | **问题总计** | **16 条** | --- ## 修复优先级建议 ``` 本迭代必须修(上线前):P0-1, P0-2, P0-3 下一迭代修(公测前):P1-1, P1-2, P1-4, P1-5 排期修(正式发布前):P1-3, P1-6, P2-1, P2-2, P2-3 技术债记录:P2-4, P2-5, 意外发现 ```