跳转至

防御性编程指南

Flyto Agent Engine 的防御性编程实践.面向引擎开发者的内部文档.

目录

设计理念

永远不要信任模型会 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: <error summary>"

上下文压缩

压缩时不应调用工具

指令层:压缩提示词开头说"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 失败降级到不压缩 + 警告.只有所有降级路径都穷尽了才停止.