agent-skills 的 sdd-cache Hook:用 HTTP 304 再验证为 Source-Driven Development 构建跨会话 WebFetch 缓存
本篇技术指南基于 hooks/SDD-CACHE.md 及其配套脚本 hooks/sdd-cache-pre.sh、hooks/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)
启用步骤共三步:
- 注册 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,即异步执行——缓存写入不阻塞主流程。
-
忽略缓存目录。确保
.claude/sdd-cache/已在.gitignore中——本仓库的 .gitignore 已包含该条目。 -
正常使用技能。按原样使用
/source-driven-development(或该技能本身),技能与 Agent 工作流零改动——缓存是完全透明的。
心智模型:URL 为键的 HTTP 资源缓存
文档给出的心智模型可以概括为一句话:这是一个以 URL 为键的 HTTP 资源缓存,新鲜度完全委托给源站的 ETag / Last-Modified——没有 TTL,键里不含 prompt。
一个容易被忽略但很关键的点是:缓存存的不是原始 HTML。WebFetch 会把每个响应交给模型、按调用方的 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时条目才会被使用; - 没有
ETag或Last-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)
- 优雅降级:开头三个依赖检查——
jq、curl、shasum(或sha256sum)任一缺失就直接exit 0放行 fetch(第 20-23 行),保证缺依赖时功能只是失效、绝不阻断 Agent 工作。 - 缓存键:
sha256(URL)截断为前 32 个十六进制字符(即 128 bit),脚本注释写明这与 post 脚本必须保持一致。 - 无验证器不命中:读取条目后若
etag和last_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/ 下生成一个文件(前提是源站返回了 ETag 或 Last-Modified);第二条命令在源站应答 304 时以退出码 2 把缓存内容写到 stderr,否则静默退出 0。
2. 真实会话端到端验证
- 按上文注册 Hook 到
.claude/settings.local.json; - 在本仓库启动一个 Claude Code 会话;
- 让 Agent 抓取一个文档页(如"fetch
https://react.dev/reference/react/useActionStateand summarize"); - 确认
.claude/sdd-cache/下出现了文件; - 用相同 prompt 再让它抓同一页;
- 确认第二次
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、不改核心"的代价。 - 没有
ETag或Last-Modified的服务器永远不会被缓存。多数官方文档站(react.dev、docs.djangoproject.com、developer.mozilla.org)都发验证器;不发的站点每次都会重新抓取。 - 行为异常的服务器可能返回错误的
304。那属于要排查的服务器 bug,而不是缓存需要防御的不变量——设计者拒绝用 TTL 来掩盖问题;发现陈旧条目就删掉它。 - 缓存是本地、按项目的,没有团队级共享缓存。文档说明要加这一层需要带签名的内容寻址存储,超出当前范围。
运行环境要求(Requirements)
jqcurlshasum或sha256sum(脚本自动探测,二者有其一即可)- Bash 3.2+
所有依赖缺失时 Hook 会静默放行、功能降级但不报错,这是 pre 脚本 开头显式设计的优雅降级路径。
小结:为什么这套设计值得借鉴
sdd-cache 解决的是一个 Agent 工程中的典型张力:重复抓取浪费 token 与延迟,盲目缓存又破坏"以官方文档为唯一事实来源"的可信性。它的回答是:把新鲜度判断完全交还给 HTTP 协议本身——304 Not Modified 既是缓存信号也是验证证据,无验证器则不缓存,宁可多抓也不猜。配合 URL-only 键 + prompt 元数据、退出码 2 + stderr 载荷的拦截约定、无 TTL 的透明缓存语义,以及依赖缺失即放行的降级策略,整对 Hook 在未改动技能定义的前提下,为 source-driven-development 的 FETCH 阶段加上了可验证、可调试、可失效的加速层。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00