首页
/ agent-skills 的 sdd-cache Hook:用 HTTP 304 再验证为 Source-Driven Development 构建跨会话 WebFetch 缓存

agent-skills 的 sdd-cache Hook:用 HTTP 304 再验证为 Source-Driven Development 构建跨会话 WebFetch 缓存

2026-09-04 22:56:51作者:曹令琨Iris

本篇技术指南基于 hooks/SDD-CACHE.md 及其配套脚本 hooks/sdd-cache-pre.shhooks/sdd-cache-post.sh,完整讲解 agent-skills 仓库中 sdd-cache 这一对 Claude Code Hook 的设计动机、配置方法、运行时机制与本地验证手段。读完后你将掌握:如何在不修改 source-driven-development 技能本身的前提下,让 Agent 重复抓取同一官方文档页时自动跳过冗余 WebFetch 请求,同时保留"每次都向源站确认内容未变"的新鲜度保证。

为什么需要 sdd-cache:缓存与"验证最新文档"的矛盾

agent-skills 中的 source-driven-development 技能要求 Agent 把每一个框架相关的实现决策都锚定在官方文档上——流程是 DETECT → FETCH → IMPLEMENT → CITE。这意味着同一个项目跨多次会话开发时,同样的文档页会被反复抓取。

把抓取内容存成本地"记忆"看似自然,但文档明确指出:这与该技能的核心承诺相矛盾——文档会变,过期的缓存会掩盖这一点。sdd-cache 的解法是:内容确实缓存在磁盘上,但每次复用前都向源站发起 HTTP 条件请求If-None-Match / If-Modified-Since)重新验证。只有当源站应答 304 Not Modified 时才命中缓存——这本身就是一次新鲜度验证,而不是单纯地读内存。

安装与配置(Setup)

启用步骤共三步:

  1. 注册 Hooks。把下面配置加入 .claude/settings.json(个人使用可放 .claude/settings.local.json)。matcher 精确锁定 WebFetch 工具:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "WebFetch",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"${CLAUDE_PROJECT_DIR}/hooks/sdd-cache-pre.sh\"",
            "timeout": 10
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "WebFetch",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"${CLAUDE_PROJECT_DIR}/hooks/sdd-cache-post.sh\"",
            "async": true,
            "timeout": 10
          }
        ]
      }
    ]
  }
}

注意两个实现细节:

  • ${CLAUDE_PROJECT_DIR} 解析为你启动 Claude Code 的目录。上面的写法适用于 Hook 脚本就放在当前项目内(如本仓库的 hooks/ 目录);若 agent-skills 安装在别处(例如 ~/agent-skills 下的共享插件),需把 ${CLAUDE_PROJECT_DIR}/hooks/... 替换为脚本的绝对路径。
  • Post 钩子标记了 "async": true,即异步执行——缓存写入不阻塞主流程。
  1. 忽略缓存目录。确保 .claude/sdd-cache/ 已在 .gitignore 中——本仓库的 .gitignore 已包含该条目。

  2. 正常使用技能。按原样使用 /source-driven-development(或该技能本身),技能与 Agent 工作流零改动——缓存是完全透明的。

心智模型:URL 为键的 HTTP 资源缓存

文档给出的心智模型可以概括为一句话:这是一个以 URL 为键的 HTTP 资源缓存,新鲜度完全委托给源站的 ETag / Last-Modified——没有 TTL,键里不含 prompt

一个容易被忽略但很关键的点是:缓存存的不是原始 HTMLWebFetch 会把每个响应交给模型、按调用方的 prompt 做后处理,所以缓存下来的其实是"某个 Agent 对页面的阅读结果"。因此:

  • 键只保留 URL,保证跨会话可复用;
  • 原始 prompt 作为元数据保存,并在命中消息中显式展示,让下一个 Agent 判断这次阅读是否仍然适用。

工作机制:两个 Hook 如何协作

每个 URL 对应一条缓存记录,以 JSON 存在 .claude/sdd-cache/<sha>.json。两个事件的分工如下:

事件 行为
PreToolUse WebFetch 若存在缓存条目,则带 If-None-Match / If-Modified-Since 发起 HEAD 请求。收到 304拦截本次 fetch,并把缓存内容经 stderr 回传给 Agent,同时附上原始 prompt 作为元数据;否则放行 fetch。
PostToolUse WebFetch 抓取响应内容,再发一次 HEAD 记录当前 ETag / Last-Modified,存入 {url, prompt, etag, last_modified, content, fetched_at}

新鲜度规则(Freshness rules)

  • 只有源站确认 304 Not Modified 时条目才会被使用;
  • 没有 ETagLast-Modified 头的条目永远不会被缓存——没有验证器就无法事后核实新鲜度,缓存它们等于信任记忆;
  • 缓存键是 sha256(url)。同一个 URL 用不同 prompt 询问会命中同一条目:缓存正文反映的是首次抓取时的 prompt,命中时该 prompt 会一并展示,由 Agent 自行决定复用还是手动重新抓取。

Agent 侧看到什么

  • 命中缓存WebFetch 被 Hook 以退出码 2 拦截。Claude Code 会把 Hook 的 stderr 载荷作为"工具错误"回传给 Agent——这是命中缓存的约定信号,而非真正的失败。载荷以 [sdd-cache] Cache hit for <url> 开头,缓存正文包在 ----- BEGIN CACHED CONTENT ----- / ----- END CACHED CONTENT ----- 标记之间,Agent 可以像 WebFetch 刚返回一样使用它。
  • 未命中或已过期WebFetch 正常执行,结果被存下来供下次使用。

技能本身没有任何改动,DETECT → FETCH → IMPLEMENT → CITE 流程照旧;Hook 只改变了 FETCH 这一步的内部行为。

源码级实现细节

结合两个脚本的源码,可以确认文档描述的每个行为在实现层面都有对应代码,且有多处防御性设计值得学习。

pre 脚本:拦截路径(hooks/sdd-cache-pre.sh

  • 优雅降级:开头三个依赖检查——jqcurlshasum(或 sha256sum)任一缺失就直接 exit 0 放行 fetch(第 20-23 行),保证缺依赖时功能只是失效、绝不阻断 Agent 工作。
  • 缓存键sha256(URL) 截断为前 32 个十六进制字符(即 128 bit),脚本注释写明这与 post 脚本必须保持一致。
  • 无验证器不命中:读取条目后若 etaglast_modified 均为空,直接放行(第 61-65 行),与文档"never cached"规则一一对应。
  • 条件 HEAD:用 curl -sI -o /dev/null -w "%{http_code}" --max-time 5 -L 发起带验证器的 HEAD,超时 5 秒,网络失败记为 000。状态码不是 304 就静默放行。
  • 安全输出:命中时用 printf 而非 heredoc 输出载荷(第 91-105 行)。源码注释解释了原因:文档正文里含反引号、$ 变量和反斜杠,未加引号的 heredoc 会触发命令替换——这是一个很实用的 shell 细节。
  • 验证时间展示:命中消息里用 date -u -r(BSD/macOS)与 date -u -d(GNU/Linux)双写兜底,把 fetched_at 转成 ISO 时间展示为 "unchanged since

post 脚本:写入路径(hooks/sdd-cache-post.sh

  • 响应形态兼容:Claude Code 当前的 tool_response 是含 bytes/code/codeText/durationMs/result/url 的对象,正文在 .result;脚本用 jq 依次回退 .output → .text → .content → .body,并对字符串形态做了分支处理,以兼容旧版/自定义集成(第 37-58 行注释明确了这一演进背景)。
  • 只取最终响应的验证器:HEAD 用 curl -sI -L 跟随重定向后,用 awk 段落模式取最后一个响应块的头——避免误抓重定向链中 301/302 中间跳的 ETag;tr -d '\r' 则确保 awk 能正确识别响应块之间的空行分隔。
  • 无验证器即清理:若源站不再返回任何验证器,post 脚本会主动 rm -f 删除旧条目(第 109-113 行),防止留下无法再验证的僵尸缓存。
  • 原子写入:用 jq -n 生成 JSON 到 <file>.$$.tmp 临时文件,成功后 mv 覆盖,失败则清理临时文件——避免 pre 脚本读到半截 JSON。

本地测试指南

文档给出了四层递进的验证方法,可直接复制执行。

1. 直接冒烟测试脚本

模拟 PostToolUse 载荷写入一条缓存,再模拟同 URL 的 PreToolUse:

# Simulate a PostToolUse payload: cache a page
echo '{
  "tool_input": {
    "url": "https://react.dev/reference/react/useActionState",
    "prompt": "extract the signature"
  },
  "tool_response": "useActionState(action, initialState) returns [state, formAction, isPending]"
}' | bash hooks/sdd-cache-post.sh

# Inspect the stored entry
ls .claude/sdd-cache/
cat .claude/sdd-cache/*.json | jq .

# Simulate the next PreToolUse on the same URL + prompt
echo '{
  "tool_input": {
    "url": "https://react.dev/reference/react/useActionState",
    "prompt": "extract the signature"
  }
}' | bash hooks/sdd-cache-pre.sh
echo "exit=$?"

预期结果:第一条命令在 .claude/sdd-cache/ 下生成一个文件(前提是源站返回了 ETagLast-Modified);第二条命令在源站应答 304 时以退出码 2 把缓存内容写到 stderr,否则静默退出 0

2. 真实会话端到端验证

  1. 按上文注册 Hook 到 .claude/settings.local.json
  2. 在本仓库启动一个 Claude Code 会话;
  3. 让 Agent 抓取一个文档页(如"fetch https://react.dev/reference/react/useActionState and summarize");
  4. 确认 .claude/sdd-cache/ 下出现了文件;
  5. 用相同 prompt 再让它抓同一页;
  6. 确认第二次 WebFetch 被拦截、返回缓存内容(会话记录中可见带 [sdd-cache] 前缀的工具错误)。

3. 新鲜度失效验证

想确认"文档变更时缓存自动失效",可人为制造 ETag 失配。注意挑选具体条目——*.json 通配在缓存超过一个文件时并不安全:

# Pick the entry you want to corrupt (swap in the actual filename)
ENTRY=.claude/sdd-cache/e49c9f378670cfbb1d7d871b6dee16d9.json

# Patch its ETag to something the origin will not recognize
jq '.etag = "W/\"stale-etag-forced\""' "$ENTRY" > "$ENTRY.tmp" && mv "$ENTRY.tmp" "$ENTRY"

# Next PreToolUse should miss (server returns 200, not 304)
echo '{"tool_input":{"url":"...", "prompt":"..."}}' | bash hooks/sdd-cache-pre.sh
echo "exit=$?"   # expect 0 (fetch allowed through)

4. 调试模式

两个 Hook 在调试模式下都会把带时间戳的事件追加写入 .claude/sdd-cache/.debug.log。开启方式二选一:

# Option A: env var (per-session)
SDD_CACHE_DEBUG=1 claude

# Option B: sentinel file (persistent)
mkdir -p .claude/sdd-cache && touch .claude/sdd-cache/.debug
# …disable with: rm .claude/sdd-cache/.debug

日志会记录 URL、检测到的 tool_response 形态、HEAD 状态码,以及每次调用命中/未命中的原因。当某次未命中看起来反常时尤其有用——最常见的原因是源站停止发出验证器。

已知限制(Known limitations)

文档对限制的陈述同样重要,逐条继承如下:

  • 正文是 prompt 定形的。命中返回的是之前那个 Agent 对页面的阅读结果,并展示原始 prompt 供当前 Agent 判断是否适用。若不适用,删除 .claude/sdd-cache/ 下的对应文件即可强制重新抓取。
  • 每次缓存写入都多花一次 HEAD。因为 Claude Code 不暴露 WebFetch 已收到的响应头,post 钩子必须向源站再查一次以捕获 ETag / Last-Modified。每次未命中多一个往返——这是"纯 Hook、不改核心"的代价。
  • 没有 ETagLast-Modified 的服务器永远不会被缓存。多数官方文档站(react.dev、docs.djangoproject.com、developer.mozilla.org)都发验证器;不发的站点每次都会重新抓取。
  • 行为异常的服务器可能返回错误的 304。那属于要排查的服务器 bug,而不是缓存需要防御的不变量——设计者拒绝用 TTL 来掩盖问题;发现陈旧条目就删掉它。
  • 缓存是本地、按项目的,没有团队级共享缓存。文档说明要加这一层需要带签名的内容寻址存储,超出当前范围。

运行环境要求(Requirements)

  • jq
  • curl
  • shasumsha256sum(脚本自动探测,二者有其一即可)
  • Bash 3.2+

所有依赖缺失时 Hook 会静默放行、功能降级但不报错,这是 pre 脚本 开头显式设计的优雅降级路径。

小结:为什么这套设计值得借鉴

sdd-cache 解决的是一个 Agent 工程中的典型张力:重复抓取浪费 token 与延迟,盲目缓存又破坏"以官方文档为唯一事实来源"的可信性。它的回答是:把新鲜度判断完全交还给 HTTP 协议本身——304 Not Modified 既是缓存信号也是验证证据,无验证器则不缓存,宁可多抓也不猜。配合 URL-only 键 + prompt 元数据、退出码 2 + stderr 载荷的拦截约定、无 TTL 的透明缓存语义,以及依赖缺失即放行的降级策略,整对 Hook 在未改动技能定义的前提下,为 source-driven-developmentFETCH 阶段加上了可验证、可调试、可失效的加速层。

登录后查看全文
热门项目推荐
相关项目推荐