# 防御性编程指南 Flyto Agent Engine 的防御性编程实践.面向引擎开发者的内部文档. ## 目录 - [设计理念](#设计理念) - [三层防御模式](#三层防御模式) - [各模块防御策略](#各模块防御策略) - [API 调用](#api-调用) - [工具调用](#工具调用) - [子代理](#子代理) - [上下文压缩](#上下文压缩) - [权限系统](#权限系统) - [输入处理](#输入处理) - [会话管理](#会话管理) - [Hook 系统](#hook-系统) - [MCP 协议](#mcp-协议) - [StrictMode 严格模式](#strictmode-严格模式) - [ToolResultPairing 配对修复](#toolresultpairing-配对修复) - [为什么这样设计](#为什么这样设计) ## 设计理念 > 永远不要信任模型会 100% 遵守指令.对每个模型交互都准备三层防御. 模型是概率性的.即使系统提示词说"不要调用工具",模型仍然可能返回 `tool_use` 块.即使参数 schema 要求 `string`,模型仍然可能返回 `null`.防御性编程是引擎可靠性的基础. 核心原则: 1. **不信任模型输出** -- 模型返回的任何东西都可能不符合预期 2. **不信任外部输入** -- 工具输出,用户输入,文件内容都可能包含异常数据 3. **每个失败路径都有兜底** -- 不存在"走到这里不可能"的代码 4. **Fail-Open 优于 Fail-Closed** -- 宁可带着降级继续运行,也不要直接崩溃 ## 三层防御模式 每个和模型交互的地方都应用三层防御: ``` 第一层:指令层(Instruction) 在提示词中明确告诉模型不要做什么。 示例:压缩提示词中说"不要调用工具,只生成摘要文本"。 第二层:参数层(Parameter) 在 API 参数中阻止不期望的行为。 示例:压缩 API 调用时不传 tools 参数,从 API 层面阻止工具调用。 第三层:兜底层(Fallback) 即使前两层都失败了,代码中仍然正确处理。 示例:压缩响应中如果出现 tool_use 块,当作普通文本处理而不是执行。 ``` 三层防御的价值在于: | 场景 | 只有指令层 | 指令+参数层 | 三层防御 | |------|-----------|-----------|---------| | 模型遵守指令 | 正常 | 正常 | 正常 | | 模型无视指令 | **工具被执行** | API 报错 | 优雅降级 | | 模型无视指令 + API 不拦截 | **工具被执行** | **工具被执行** | 优雅降级 | ## 各模块防御策略 ### API 调用 与模型 API 通信是最关键的交互点. **空响应** ``` 指令层:无(API 空响应不可控) 参数层:无 兜底层:检测 content block 列表为空 → 重试一次 → 重试仍失败返回 "模型未响应" ``` 代码位置:`pkg/engine/engine.go` runLoop 中处理 `message_stop` 事件时 **不完整的 JSON(tool_use input)** tool_use 块的 input 字段通过多个 `input_json_delta` 事件逐步拼接.如果 SSE 流中断,JSON 可能不完整. ``` 指令层:无 参数层:无 兜底层:json.Unmarshal 失败 → 返回 schema 提示给模型 → 模型可重试 ``` 代码位置:`internal/api/client.go` parseSSE, `pkg/tools/orchestrator.go` 执行前的 JSON 解析 **SSE 流中断** ``` 指令层:无 参数层:无 兜底层:检测最后一个事件是否是 message_stop → 不是则标记为不完整 → 根据已收集内容决定重试还是返回部分结果 ``` **429/529 重试** ``` 指令层:无 参数层:无 兜底层: - 429(速率限制):指数退避重试,最多 3 次 - 529(过载):前台请求重试,后台请求直接失败(防止级联雪崩) - 401:清除可能的缓存 → 提示用户检查 API key ``` **stop_reason 不可靠** ``` 指令层:无 参数层:无 兜底层:不仅依赖 API 返回的 stop_reason,引擎自己追踪 content block 类型。 如果收到了 tool_use block,即使 stop_reason 不是 "tool_use", 也进入工具执行流程。 ``` ### 工具调用 **工具不存在** 模型可能请求一个不存在的工具(拼写错误或幻觉). ``` 指令层:系统提示中列出所有可用工具 参数层:API tools 参数只包含注册过的工具 兜底层:Registry.Get() 返回 nil → 返回友好错误消息 "Tool 'XxxTool' not found. Available tools: Bash, Read, Edit, ..." 模型可据此调整。 ``` 代码位置:`pkg/tools/orchestrator.go` ExecuteBatch **工具输入 JSON 无效** ``` 指令层:每个工具的 description 中说明 required 字段 参数层:InputSchema() 返回 JSON Schema(API 侧验证) 兜底层:json.Unmarshal 失败 → 返回 schema 提示 "Invalid input for Bash tool. Expected: {command: string, ...}" 不 panic,不 crash。 ``` **工具执行超时** ``` 指令层:description 中说明超时限制 参数层:context.WithTimeout 硬限制 兜底层:超时后优雅终止(SIGTERM → 5s → SIGKILL → 进程组) 返回已收集的部分输出 + "[command timed out after Xs]" ``` 代码位置:`pkg/tools/builtin/bash.go` executeForeground **工具返回非 UTF-8** ``` 指令层:无 参数层:无 兜底层:isBinaryContent() 检测 null bytes / 无效 UTF-8 / magic bytes 检测到二进制 → 替换为 "Binary output (N bytes). Pipe to file if needed." ``` **工具结果超大** ``` 指令层:无 参数层:无 兜底层: 1. stdout/stderr 独立截断(stdout 200KB, stderr 56KB, 总计 256KB) 2. 截断保留头 80% + 尾 20%(保留开头的结构和结尾的总结) 3. 超过 MaxInlineResultChars (30000) → 存磁盘 → 模型拿到摘要 + 路径 ``` 代码位置:`pkg/tools/builtin/bash.go` truncateStream, `pkg/engine/result_store.go` ### 子代理 **递归生成防护** 子 Agent 不能递归创建子 Agent(无限递归将耗尽资源). ``` 指令层:子 Agent 系统提示明确说"不要使用 Agent 工具" 参数层:子 Agent 的工具列表中移除 Agent/TaskStop 等管理工具 兜底层:运行时检测递归深度,超过阈值拒绝创建 ``` 代码位置:`pkg/engine/subagent.go` **子代理超预算** ``` 指令层:子 Agent 系统提示提醒 token 预算 参数层:MaxTurns 硬限制 兜底层:超了也跑完当前轮再停(不中断正在执行的工具) ``` **子代理失败** ``` 指令层:无 参数层:独立 context(子代理 panic 不传播到父代理) 兜底层:recover → 返回错误摘要给父 Agent "Sub-agent failed: " ``` ### 上下文压缩 **压缩时不应调用工具** ``` 指令层:压缩提示词开头说"Do NOT call any tools. Only output a text summary." 参数层:压缩 API 调用不传 tools 参数 兜底层:如果响应中仍出现 tool_use block → 提取 input JSON 作为文本内容 ``` 代码位置:`pkg/context/compact.go` **压缩失败** ``` 指令层:无 参数层:无 兜底层:电路断路器模式: - 第 1 次失败:重试 - 第 2 次失败:降级到 MicroCompact(简单截断旧工具结果) - 第 3 次失败:停止尝试压缩,发出 WarningEvent ``` **压缩结果为空** ``` 指令层:提示词要求"输出至少包含关键文件路径和当前任务状态" 参数层:无 兜底层:摘要为空 → 使用最近 N 条消息的拼接作为兜底摘要 ``` ### 权限系统 **权限 Handler 超时** ``` 指令层:无 参数层:PermissionHandler 调用包装 context.WithTimeout(默认 5 分钟) 兜底层:超时 → 默认拒绝(不无限等待) ``` 代码位置:`pkg/permission/checker.go` **权限 Handler 返回异常** ``` 指令层:无 参数层:无 兜底层:error != nil → 当作拒绝处理 + 记录错误日志 + 返回友好提示给模型 ``` **权限规则解析失败** ``` 指令层:无 参数层:无 兜底层:单条规则解析失败 → 跳过该规则 → 警告日志 → 其他规则正常工作 ``` 代码位置:`pkg/permission/rules.go` ### 输入处理 **文件引用不存在** ``` 兜底层:os.ReadFile 失败 → 提示 "File not found: /path/to/file" → 保留原始 @path 文本 ``` **图片读取失败** ``` 兜底层:读取失败 → 提示 "Unable to read image" → 继续处理文本部分(不丢弃整条消息) ``` **输入过大** ``` 参数层:最大 100,000 字符限制 兜底层:超长 → 截断 + 提示用户拆分 ``` 代码位置:`pkg/engine/input.go` ### 会话管理 **会话保存失败(磁盘满)** ``` 兜底层:os.WriteFile 失败 → WarningEvent(警告但不中断当前对话) ``` **会话恢复文件损坏** ``` 兜底层:json.Unmarshal 失败 → 提示用户并开始新会话(不 crash) ``` 代码位置:`pkg/engine/session_persist.go`(原子写入:先写 .tmp 再 rename) **并发 Send 到同一 Session** ``` 参数层:sync.Mutex 保护(已实现) 兜底层:排队等待而非报错 ``` 代码位置:`pkg/engine/session.go` ### Hook 系统 Hook 是用户配置的外部 shell 命令,不可信任. **Hook 命令不存在** ``` 兜底层:exec 失败 → 跳过此 Hook → 继续执行后续 Hook 和主流程 ``` **Hook 输出非 JSON** ``` 兜底层:json.Unmarshal 失败 → 当作纯文本日志记录 → 不影响主流程 ``` **Hook 超时** ``` 参数层:每个 Hook 有独立超时(默认 30 秒) 兜底层:超时 → 终止 Hook 进程 → 继续主流程(fail-open) ``` 代码位置:`pkg/hooks/executor.go` ### MCP 协议 MCP 服务器是外部进程,可能随时崩溃. **MCP 服务器启动失败** ``` 兜底层:跳过该服务器 → WarningEvent → 其他服务器不受影响 ``` **MCP 工具调用超时** ``` 参数层:context.WithTimeout 兜底层:超时 → 返回错误消息给模型 → 模型可选择其他工具 ``` **MCP 服务器崩溃** ``` 兜底层:检测连接断开 → 尝试重连一次 → 重连失败从工具列表移除 → WarningEvent ``` 代码位置:`internal/mcp/client.go`, `internal/mcp/manager.go` ## StrictMode 严格模式 `pkg/engine/strict.go` StrictMode 是防御性编程的"审计模式".生产环境中引擎静默修复各种异常,但安全评估和测试环境需要知道这些异常的存在. ### 背景(inc-4977) 安全评估时引擎静默注入了合成 tool_result,导致模型看到的上下文与实际不一致,评估结果不可信.StrictMode 解决这个问题:开启后,所有静默修复变成 panic,保证评估环境的上下文纯净. ### 与防御性编程的关系 StrictMode 不是否定防御性编程,而是让防御行为可控: ``` 生产环境(StrictMode nil): 异常 → 静默修复 → Observer.Event 记录 → 用户无感知 安全评估(StrictMode 全开): 异常 → panic → 评估中断 → 开发者立即知道问题 测试环境(StrictMode 部分开): 配对异常 → panic(必须精确) 压缩失败 → 降级(允许不精确) ``` ### Check 模式 `StrictMode.Check(condition, enabled, observer, detail)` 统一了严格模式和可观测性: - `enabled=true` -- `panic("strict mode violation: ...")` - `enabled=false` -- `observer.Event("strict_mode_would_fail", ...)` 每个调用点只需一行代码,不需要自己写 if/else + observer.Event(). ### 三个检查维度 | 方法 | 条件 | 调用位置 | |------|------|---------| | `CheckToolResultPairing` | tool_use/tool_result 配对异常 | ToolResultPairingNormalizer | | `CheckCompactFailure` | 压缩失败 | CompactTiered | | `CheckNormalizerError` | 规范化异常 | NormalizePipeline | 代码位置:`pkg/engine/strict.go` ## ToolResultPairing 配对修复 `pkg/engine/norm_tool_result_pairing.go` tool_use/tool_result 配对错误是生产中最常见的 API 400 根因.早期方案只处理"孤立 tool_result"(OrphanToolResultRemover),实际还有 3 种 case. ### 4 种防御场景 **Case 1: tool_use 无 tool_result(API 400 防护)** ``` 原因:会话中断、压缩截断、异常退出 后果:API 返回 400(tool_use 必须有对应 tool_result) 修复:注入合成 tool_result,标记 IsError=true 文案:"[Tool result not available - session may have been interrupted...]" ``` **Case 2: tool_result 无 tool_use(委托)** ``` 原因:消息历史损坏 修复:委托给 OrphanToolResultRemover(Priority 10) ``` **Case 3: 重复 tool_use ID(去重)** ``` 原因:压缩合并、消息回放 后果:API 行为未定义 修复:保留第一个,后续去重 ``` **Case 4: 重复 tool_result ID(去重)** ``` 原因:网络重传、SDK 重试 后果:浪费 token,可能混淆模型 修复:保留第一个,后续去重 ``` ### 与可观测性的集成 每次修复都通过 Observer 记录完整信息: ``` observer.Event("tool_result_pairing_repaired", { "repairs": ["synthetic_tool_result:tu_001", "duplicate_tool_use:tu_002"], "repair_count": 2, "message_count_before": 5, "message_count_after": 6, "diagnostic": "[0] user(text); [1] assistant(tool_use=[tu_001,tu_002]); ..." }) ``` 诊断快照只包含结构信息(角色,content 类型,ID),不含消息内容,可安全发到外部监控. ### Priority 排序的防御意义 ToolResultPairingNormalizer 的 Priority 为 8,在 OrphanToolResultRemover(10) 之前执行.顺序不能反:先注入合成 tool_result,再由 OrphanToolResultRemover 清理孤立的--如果反过来,注入的合成 tool_result 不会被二次检查. 代码位置:`pkg/engine/norm_tool_result_pairing.go` ## 为什么这样设计 ### 踩坑经验 1. **模型返回空 content block** -- 早期版本直接 panic(index out of range).修复后加入空响应检测和重试. 2. **Bash 输出撑爆上下文** -- 一次 `npm install` 输出 500KB 日志,全部送入下一轮 API 调用导致超 token 限制.修复后实现分离截断策略和 ResultStore 磁盘持久化. 3. **子 Agent 递归** -- 模型在子 Agent 中调用 Agent 工具创建孙 Agent,无限递归直到 OOM.修复后三层防御:提示词 + 工具过滤 + 深度检测. 4. **权限 Handler 不返回** -- HTTP 模式下客户端断连后 PermissionHandler 永远不返回,goroutine 泄漏.修复后加入超时默认拒绝. 5. **压缩时调用工具** -- 模型在压缩调用中返回 tool_use(无视了"不要调用工具"的提示).修复后不传 tools 参数 + 兜底处理. 6. **tool_use 缺少 tool_result 导致 API 400** -- 会话中断后恢复,压缩截断了 tool_result 但保留了 tool_use,后续 API 调用全部 400.ToolResultPairingNormalizer 注入合成 tool_result 解决. 7. **安全评估结果不可信(inc-4977)** -- 引擎静默注入合成 tool_result 后,模型基于合成内容做评估,结果偏差.StrictMode 让评估环境 panic 而不是静默修复. 8. **重复 tool_use ID 导致不可预测行为** -- 压缩合并后出现重复 tool_use ID,API 响应行为不确定.ToolResultPairingNormalizer 去重修复. ### 设计权衡 - **Fail-Open vs Fail-Closed**: Hook 系统选择 fail-open(Hook 失败不阻塞主流程),因为 Hook 是增强功能而非核心功能.权限系统选择 fail-closed(异常时拒绝),因为安全是刚性需求. - **重试 vs 快速失败**: 前台 API 调用(用户等待中)选择重试,后台调用(子 Agent,Dream 等)选择快速失败,防止级联雪崩. - **降级 vs 停止**: 能降级就降级.压缩失败降级到 MicroCompact,MicroCompact 失败降级到不压缩 + 警告.只有所有降级路径都穷尽了才停止.