本页以本机 ~/.claude/workflows/workflow_research_simple.js(427 行)为样本,逐段拆解一个真实、可运行的深度研究工作流。原函数语义与运行时约束见 principles;本页关注它在真实脚本里怎么被用、为什么这么用。

脚本原文存档:raw/from_llm/workflow_research_simple.js

一句话概括

这是一个 fan-out → 对抗验证 → 综合 的深度研究工作流:把研究问题分解成多个搜索角度,每个角度并行搜索,对去重后的来源并行抓取并抽取可证伪声明,再对声明做多票对抗验证,最后交给一个综合 Agent 产出带引用的报告。

Scope        1 个 agent   分解问题 → 2 个搜索角度
Search       2 个 agent   每个角度一个 WebSearch
Fetch        ≤3 个 agent   URL 去重后抓取、抽取 claims
Verify       N×2 个 agent  每条 claim 2 票对抗验证(2 票驳回即杀死)
Synthesize   1 个 agent   合并语义重复、按置信度排序、引用来源

meta 块(第 1-6 行)

export const meta = {
  name: 'workflow_research-simple',
  description: 'Deep research harness — fan-out web searches, fetch sources, adversarially verify claims, synthesize a cited report.',
  whenToUse: 'When the user wants a deep, multi-source, fact-checked research report on any topic. BEFORE invoking, check if the question is specific enough to research directly — if underspecified (e.g., "what car to buy" without budget/use-case/region), ask 2-3 clarifying questions to narrow scope. Then pass the refined question as args, weaving the answers in.',
  phases: [{"title":"Scope","detail":"Decompose question (from args) into 2 search angles"}, ...],
}
字段作用本脚本的选择
name保存为命令后的斜杠名,也是注册表里的键workflow_research-simple(注意是下划线+连字符混用)
description权限对话框和工作流列表里的一行说明一句话讲清五个阶段
whenToUse给 Claude 判断何时该调用它;可写触发前的前置检查明确要求:问题太模糊时先问 2-3 个澄清问题,再把答案织进 args
phases[]进度视图的阶段分组,detail 是每个阶段的说明五个阶段与 phase() 调用标题逐字匹配

meta 必须是纯字面量:不能有变量、函数调用、展开运算符或模板插值,否则命令会从 / 自动补全里静默消失。whenToUse 是可选字段,但本脚本用得很有代表性——它把「先澄清再研究」的策略直接编码进工作流自身的元数据,而不是留给调用者记忆。

常量与 Schema(第 12-88 行)

脚本顶部定义 4 个调参常量和 5 个 JSON Schema:

const VOTES_PER_CLAIM = 2
const REFUTATIONS_REQUIRED = 2
const MAX_FETCH = 3
const MAX_VERIFY_CLAIMS = 3

这四个常量就是整个工作流的成本旋钮:每条 claim 投 2 票、2 票驳回才杀死、最多抓 3 个来源、最多验证 3 条 claim。改这几个数字就能线性缩放 token 消耗。

五个 Schema 分别约束五个阶段的输出:

Schema约束的输出关键设计
SCOPE_SCHEMAquestion / summary / angles[]angles 限 1-3 项,每项带 label/query/rationale
SEARCH_SCHEMAresults[]maxItems: 2 控制扇出宽度;relevance 用 enum 限定 high/medium/low
EXTRACT_SCHEMAclaims[] + sourceQualityclaim 必须带 quote(原文引用)和 importance enum
VERDICT_SCHEMArefuted / evidence / confidencerefuted 是布尔值,直接驱动三态裁定
REPORT_SCHEMAsummary / findings[] / caveatsfinding 带 sources[]、evidence、vote,保证可追溯

Schema 不是文档,是运行时契约:agent() 拿到 schema 后会强制子 Agent 调用 StructuredOutput 工具,校验失败自动重试。所以脚本下一步能直接写 found.files、scope.angles、verdict.refuted,不需要解析自然语言。

Phase 0:Scope(第 90-112 行)

phase("Scope")
const QUESTION = (typeof args === "string" && args.trim()) || ""
if (!QUESTION) {
  return { error: "No research question provided. ..." }
}
const scope = await agent(
  "Decompose this research question into complementary search angles.\n\n" + ...,
  { label: "scope", schema: SCOPE_SCHEMA }
)
if (!scope) {
  return { error: "Scope agent returned no result — cannot decompose the research question." }
}
log("Q: " + QUESTION.slice(0, 80) + ...)
log("Decomposed into " + scope.angles.length + " angles: " + ...)

三个值得学的写法:

  1. args 类型守卫:typeof args === "string" 既排除了 undefined(没传参数),也排除了对象/数组(传错类型),再 .trim() 排除空白字符串。
  2. 早返回 error 对象而不是抛异常:参数缺失和 scope 失败都返回 { error },让调用方拿到结构化结果,而不是让整个运行以栈错误收场。
  3. agent() 返回 null 的显式检查:用户跳过该 Agent 或遇到不可恢复 API 错误时返回 null,这里立刻短路,避免后面 scope.angles.length 抛 TypeError。

安全预处理:URL 归一化与标签净化(第 114-159 行)

这是全脚本最容易被跳过、但工程价值最高的部分。工作流的中间结果里混着来自互联网的不可信字符串(URL、页面标题),它们会进入进度视图的 label,进而显示到终端。脚本为此做了两层防护。

URL 去重:正则而不是 new URL()

const URL_HOST_PATTERN = /^[a-z][a-z0-9+.-]*:\/\/(?:[^/?#\\]*@)?(?:www\.)?([^/:?#@\\]+)(?::\d+)?([^?#]*)/i
const normURL = u => {
  const m = String(u).match(URL_HOST_PATTERN)
  return m ? (m[1] + m[2].replace(/\/$/, "")).toLowerCase() : String(u).toLowerCase()
}

注释解释了为什么不用 URL 全局对象:workflow 沙箱是一个裸 ECMAScript realm,没有 URL 全局。正则的写法还有安全考虑:

  • userinfo 用贪婪的 [^/?#\\]*@,因为 WHATWG 在最后一个 @ 处切分 authority。如果停在第一个 @,x@trusted.com@evil.com 会被误判成 trusted.com,而实际请求会打到 evil.com。
  • host 字符类排除 @,保证 userinfo 组能吞掉所有 @ 直到最后一个。
  • 排除反斜杠 \,因为 WHATWG 对 http(s) 把 \ 当路径分隔符,宽松匹配会把 evil.com\@trusted.com 误标为可信。

normURL 保留原始 host 捕获值作为去重键(不做净化),因为净化可能让两个不同 URL 碰撞。

标签净化:防终端注入与视觉伪装

const LABEL_CAP = 40
const LABEL_STRIP = /[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\u0022\u201c-\u201f\u2033\u2036\u275d\u275e\u301d\u301e\uff02]/g
const STRICT_HOST = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/

LABEL_STRIP 删掉四类字符,每一类都对应一种攻击面:

类别例子危害
C0/C1 控制符\x1b(ESC)、CSI伪造 ANSI 转义序列,改写终端显示
Unicode bidi 覆盖/隔离U+202A-202E、U+2066-2069视觉上重排文本,让标签显示成另一个域名
零宽格式字符U+200B-200F、U+FEFF隐藏或拆解标签文字
引号同形字家族"、U+201C-201F、U+301D 等提前闭合引号,在引号后伪造 host 形状的文本
const quotedLabel = s => {
  const cps = Array.from(stripLabelChars(s))
  return '"' + cps.slice(0, LABEL_CAP).join("").trim() + (cps.length > LABEL_CAP ? "\u2026" : "") + '"'
}

quotedLabel 用 Array.from 按码点截断,避免把代理对拆成两半;被截断时把省略号放在引号内部,这样缩短过的字符串不可能冒充完整值。

真正渲染 host 时还有一层判断(第 246-251 行):只有当捕获的 host 是完整的、未截断的、严格 ASCII 合法主机名,才输出裸的 fetch:<host> 标签;任何偏差都退回到带引号+省略号的 quotedLabel。这样非 ASCII 的 IDN 同形字(如西里尔字母的 аmazon.com)或含非法字符的 host 都不会伪装成真实域名。

三个 Prompt 构造器(第 161-200 行)

脚本用三个纯函数生成子 Agent 的提示词,把「数据」和「指令」拼在一起:

  • SEARCH_PROMPT(angle):让 searcher 用给定 query 搜索,返回 4-6 条结果,按原始问题而非搜索词的相关性排序,跳过 SEO 垃圾。
  • FETCH_PROMPT(source, angle):抓取单个来源,评估来源质量(primary/secondary/blog/forum/unreliable),抽取 1-2 条可证伪的声明,每条必须带原文引用。
  • VERIFY_PROMPT(claim, v):对抗验证器的提示词,是全脚本质量设计的核心。

VERIFY_PROMPT 的对抗性体现在几处措辞:

Be SKEPTICAL. Try to REFUTE this claim. ≥2/2 refutations kill it.
...
**refuted=true** if: unsupported by quote / contradicted / low-quality source for strong claim / outdated / marketing fluff.
**refuted=false** ONLY if: claim is well-supported, current, and source quality matches claim strength.
Default to refuted=true if uncertain.
  • 明确要求验证者站反方,而不是「评估这个声明是否正确」。
  • 给出 5 条检查清单:引用是否支持声明、是否有反证、来源质量是否匹配声明强度、是否过时、是否是营销话术。
  • 不确定时默认驳回,把举证责任压给声明方。
  • refuted=false 的门槛写成 ONLY if,收窄通过条件。

Pipeline 扇出:Search → Fetch(第 202-272 行)

const searchResults = await pipeline(
  scope.angles,
 
  angle => agent(SEARCH_PROMPT(angle), {
    label: "search:" + angle.label, phase: "Search", schema: SEARCH_SCHEMA
  }).then(r => {
    if (!r) return null
    log(angle.label + ": " + r.results.length + " results")
    return { angle: angle.label, results: r.results }
  }),
 
  searchResult => {
    // URL 去重、预算裁剪,然后:
    return parallel(
      novel.map(source => () => agent(FETCH_PROMPT(source, searchResult.angle), {
        label: "fetch:" + sourceLabel, phase: "Fetch", schema: EXTRACT_SCHEMA,
      }).then(ext => { ... }).catch(e => { ... })
    )
  }
)

这段把三种原函数组合在一起,每一层都有明确理由:

pipeline 的两个 stage 与无 barrier 语义

pipeline(items, stage1, stage2) 让每个 angle 独立流经两个阶段:angle A 可以在 Fetch 阶段时 angle B 还在 Search。墙钟时间等于最慢的单个 angle 链,而不是「等所有 Search 完再统一 Fetch」。每个 stage 回调收到 (prevResult, originalItem, index),所以第二个 stage 能同时拿到搜索结果和原始 angle。

stage 内用 phase: 选项而不是全局 phase()

两个 stage 里调用 agent() 时都显式传 phase: "Search" / phase: "Fetch",而不是靠前面某处调用 phase() 设置全局状态。原因在官方参考里写得很清楚:pipeline/parallel 的 stage 并发执行,全局 phase() 状态会有竞态;用 phase: 选项让每个 Agent 自己声明分组,同一个 phase 字符串归到同一个进度框。

parallel(thunks) 的 thunk 要求

novel.map(source => () => agent(...))

必须传返回 Promise 的函数,不能直接传 Promise。如果写成 novel.map(source => agent(...)),所有 agent() 会在 parallel() 被调用前就立刻启动,失去并发控制和错误处理。官方运行时会对非函数项直接抛 TypeError: parallel() expects an array of functions, not promises。

.then 与 .catch 的容错分工

.then(ext => {
  if (!ext) return null        // 用户跳过 → null,直接丢弃
  return { url, title, angle, sourceQuality, publishDate, claims: [...] }
}).catch(e => {
  log("fetch failed: ...")
  return { url, title, angle, sourceQuality: "unreliable", claims: [] }
})
  • agent() 因用户跳过或终态 API 错误返回 null 时,走 .then 的分支返回 null,最终被 .filter(Boolean) 丢掉——不会误标成「unreliable 来源」。
  • 真正抛出的异常(如 fetch 失败)走 .catch,转成一个 sourceQuality: "unreliable"、空 claims 的占位对象,保留在来源列表里便于透明报告。

这个区分很重要:「Agent 没跑」和「来源不可靠」是两件不同的事,不能混为一谈。

共享可变状态与去重

const seen = new Map()
const dupes = []
const budgetDropped = []
let fetchSlots = MAX_FETCH

这三个变量在多个并发 stage 回调里被读写。JavaScript 单线程事件循环保证同步代码块不会被打断,所以这里的 seen.has / seen.set / fetchSlots-- 不需要锁;但要注意它们只在同步的 dedup 函数里操作,没有 await 穿插,否则就会产生竞态。

刻意的 barrier:全局 claim 池(第 274-292 行)

const allSources = searchResults.flat().filter(Boolean)
const allClaims = allSources.flatMap(s => s.claims)
const rankedClaims = [...allClaims]
  .sort((a, b) => (impRank[a.importance] - impRank[b.importance]) || (qualRank[a.sourceQuality] - qualRank[b.sourceQuality]))
  .slice(0, MAX_VERIFY_CLAIMS)
 
// ─── Verify: 3-vote adversarial ───
// Barrier here is intentional — claim pool must be fully assembled before ranking/verification.

这里从 pipeline 的流式处理切回了全量 barrier。await pipeline(...) 本身就是一个等待全部完成的边界,脚本用它拿到完整的 searchResults 后再做跨 item 聚合:扁平化、按重要性和来源质量排序、截取前 3 条。官方对 barrier 的判据正是这一类——只有当 stage N 需要 stage N-1 的全部结果做去重/排序/early-exit 时才值得同步;如果只是 flatten/map/filter,应该把变换放进 pipeline stage 里。

Verify:嵌套 parallel 与三态裁定(第 294-355 行)

const voted = (await parallel(
  rankedClaims.map(claim => () =>
    parallel(
      Array.from({ length: VOTES_PER_CLAIM }, (_, v) => () =>
        agent(VERIFY_PROMPT(claim, v), { label: "v" + v + ":" + ..., phase: "Verify", schema: VERDICT_SCHEMA })
      )
    ).then(verdicts => {
      const valid = verdicts.filter(Boolean)
      const refuted = valid.filter(v => v.refuted).length
      const errored = VOTES_PER_CLAIM - valid.length
      const survives = valid.length >= REFUTATIONS_REQUIRED && refuted < REFUTATIONS_REQUIRED
      const isRefuted = refuted >= REFUTATIONS_REQUIRED
      return { ...claim, verdicts: valid, refutedVotes: refuted, erroredVotes: errored, survives, isRefuted }
    })
  )
)).filter(Boolean)

外层 parallel 按 claim 扇出,内层 parallel 按票扇出,形成二维并发。裁定逻辑区分三种结局:

结局条件含义
survives有效票数 ≥ 2 且驳回票 < 2通过验证
isRefuted驳回票 ≥ 2经实质裁决被驳回
两者都不是有效票数 < 2无法裁决——验证 Agent 报错/被跳过

第三态是这段代码最值得学的设计。如果简单地写「refuted 票数过半就杀死,否则存活」,那么当两个验证 Agent 都因限流失败(返回 null)时,valid.length === 0、refuted === 0,声明会被误判为「存活」。脚本用 valid.length >= REFUTATIONS_REQUIRED 把基础设施失败和声明被驳回分开,最终报告里分别列出 refuted 和 unverified,并明确告诉用户「这是基础设施故障,不是研究结论」。

后续的分支返回也延续了这个区分:如果 confirmed.length === 0,会根据 killed 和 unverified 的数量组合出三种不同的 summary,而不是笼统说「没找到结论」。

Synthesize:从结构化数据到最终报告(第 357-427 行)

phase("Synthesize")
const block = confirmed.map((c, i) => {
  const best = c.verdicts.filter(v => !v.refuted).sort((a, b) => confRank[a.confidence] - confRank[b.confidence])[0]
  return "### [" + i + "] " + c.claim + "\n" +
    "Vote: " + ... + " · Source: " + c.sourceUrl + " (" + c.sourceQuality + ")\n" +
    "Quote: \"" + c.quote + "\"\nVerifier evidence (" + best.confidence + "): " + best.evidence + "\n"
}).join("\n")
 
const report = await agent(
  "## Synthesis: research report\n\n" + ... + "## Confirmed claims\n" + block + killedBlock + unverifiedBlock + ...,
  { label: "synthesize", schema: REPORT_SCHEMA }
)

综合阶段把已确认的声明、被驳回的声明、无法验证的声明全部拼进提示词,让综合 Agent 在写报告时能区分「事实」和「透明记录」。最终 return 的对象包含:

  • report 的三个字段(summary/findings/caveats)
  • refuted、unverified 的明细
  • sources 列表(含每个来源的质量和 claim 数)
  • stats:角度数、抓取来源数、抽取 claim 数、验证数、确认/驳回/未验证数、URL 重复数、预算丢弃数、总 Agent 调用数

stats.agentCalls 用公式 1 + angles + sources + voted*VOTES + 1 估算整次运行的规模,方便调用方核对实际消耗。综合 Agent 返回 null 时也有兜底:返回未合并的已验证 claims,而不是让整个运行失败。

源函数用法小结

上文按执行顺序讲了每个原函数在脚本里的角色;签名、全部选项、返回值与失败语义、常见错误和写作检查清单见 source-functions。样本里最值得记住的五条:

原函数样本里的用法要点
agent5 次调用,全部带 schema跨阶段数据全结构化;null 有明确归属
pipelinepipeline(angles, searchStage, fetchStage)stage 间无 barrier;stage 内用 phase: 选项
parallelFetch 扇出、Verify 内层按票扇出传 thunk 不传 Promise;失败位置为 null
phase / log串行阶段用全局 phase(),并发阶段用 agent({ phase })避免全局阶段状态竞态;log 截断不可信文本
argstypeof args === "string" && args.trim()类型守卫 + 早返回,不用非确定性来源

工程模式提炼

从这 427 行里可以抽出 8 条可复用规则:

  1. 结构化交接:所有跨 Agent 数据都走 schema,不解析自然语言。
  2. pipeline 优先:默认用无 barrier 的 pipeline;只在需要跨 item 全量聚合时才同步。
  3. stage 内显式 phase::并发阶段用 agent({ phase }),不用全局 phase()。
  4. 对抗验证:验证者提示词明确要求 refute,不确定默认驳回,多票制裁决。
  5. 三态裁定:区分「存活」「被驳回」「无法验证」,不让基础设施失败伪装成研究结论。
  6. null 全程有归属:每个 agent() 的失败路径都被显式处理,绝不让 null 流到会抛异常的地方。
  7. 不可信输入净化:来自网络的 URL/标题进入 label 前,剥离控制符、bidi、零宽字符和引号同形字,并按码点截断。
  8. 成本可调:把票数、抓取数、验证数提成顶部常量,改数字即可缩放 token 消耗。

已知不一致

脚本第 294 行注释写的是 Verify: 3-vote adversarial,meta.phases 的 detail 也写 3-vote adversarial verification per claim (need 2/3 refutes to kill),但实际常量是:

const VOTES_PER_CLAIM = 2
const REFUTATIONS_REQUIRED = 2

也就是说实际运行的是 2 票制、2 票全驳回才杀死,不是 3 票 2/3。这是脚本从其他架构移植时留下的注释/常量不一致,引用或改造时以常量为准。如果确实想要 3 票 2/3,应该把 VOTES_PER_CLAIM 改成 3、REFUTATIONS_REQUIRED 改成 2。

相关