Claude Code 101|02|Input Pipeline

Claude Code 并不是把用户输入原样交给模型,而是先经过 prompt、slash command、bash mode、附件、hook context 等多条路径的归一化。

Claude Code 101|02|Input Pipeline

Agent Runtime 的第一步不是调用模型,而是判断“用户刚才输入的到底是什么”。在普通聊天产品里,这个问题通常被简化成字符串处理:拿到 textarea 内容,作为 user message 发给模型。代码 Agent 不能这样做。Claude Code 的输入可能是一段自然语言、一个 slash command、一条 bash mode 命令、粘贴的大段文本、一张图片、IDE selection、桥接过来的远程消息、hook 注入的上下文,或者系统排队生成的 meta prompt。

如果这些输入都被原样塞进模型,系统会同时失去三件事:本地命令无法短路执行,控制信号会污染用户消息,权限和上下文策略也无法在进入模型前生效。

Input Pipeline 要解决的工程问题,就是把用户可见的输入编译成 runtime 可执行的事件:哪些内容进入 messages,哪些输入应该本地处理,哪些字段会改变模型、工具、权限或下一步输入,哪些上下文应当作为 attachment 注入,而不是伪装成用户手写文本。

工程问题

一个简单 Agent demo 通常有这样的形状:

async function run(raw: string) {
  return model.complete([{ role: 'user', content: raw }])
}

这个接口看似直接,却无法支撑 Claude Code 这样的产品。

第一,用户输入不一定是 prompt。/help/compact/model/agents 这类命令是在控制 runtime,而不是向模型提问。把它们发给模型只会让模型猜测用户意图。

第二,用户输入不一定需要模型。bash mode 表示用户明确要求执行本地命令;某些 local slash command 只需要更新状态或显示 UI;hook 也可能阻止继续执行。这些路径应该返回 shouldQuery=false,直接结束或进入下一条输入,而不是浪费一次模型调用。

第三,用户输入不只是文本。图片、粘贴内容、IDE selection、文件引用、hook context、nested memory attachment 都可能参与同一轮输入。它们需要被编码成结构化 content blocks 或 attachment messages,并经过大小、格式、预算和可见性控制。

第四,用户输入可能携带控制副作用。它可能覆盖 allowed tools、切换模型、改变 thinking effort、触发 skill discovery、预填下一条输入、或修改会话中的消息数组。Input Pipeline 如果只是返回字符串,就无法表达这些副作用。

因此,Input Pipeline 更接近编译器,而不是 sanitizer。它读取 raw input 和 runtime context,输出 messages 与控制字段。

概念边界

第 01 篇把 Claude Code 放在 Agent Runtime 视角下理解。Input Pipeline 就是这个 runtime 的入口边界:它位于产品宿主和 Agent Loop 之间。

产品宿主负责收集输入。交互式 REPL 收集键盘输入、粘贴、图片、IDE selection、队列命令和桥接消息;SDK 路径通过 src/QueryEngine.tssubmitMessage() 接收 prompt 或 content blocks。输入一旦进入 runtime,就会交给 src/utils/processUserInput/processUserInput.ts 处理。

Input Pipeline 之后才是 QueryEngine 和 Agent Loop。processUserInput() 返回的结果包含 messagesshouldQueryallowedToolsmodeleffortresultTextnextInputsubmitNextInput 等字段。QueryEngine 根据这些字段决定是否写 transcript、是否输出本地结果、是否进入 src/query.ts 的模型-工具循环。

这条边界可以概括为:

raw input / content blocks
        │
        ▼
Input Pipeline
        │
        ├─ messages: model-visible or transcript-visible content
        ├─ shouldQuery: whether to enter Agent Loop
        ├─ control: model, effort, allowed tools, next input
        └─ local result: resultText / local JSX / command output

这个边界的重要性在于,它把“用户想说什么”和“runtime 应该怎么处理这次输入”分开了。

机制一:输入先被分类,而不是先被发送

Claude Code 的输入层首先把输入放在 mode 和 context 中理解。它处理的不只是 input 字符串,还包括运行模式、粘贴内容、IDE selection、历史消息、query source、权限函数、bridge origin 和 meta prompt 等上下文。

这个返回类型很说明问题:messages 只是结果之一,旁边还有 shouldQueryallowedToolsmodeleffortresultTextnextInputsubmitNextInput。也就是说,输入处理结果是一个 runtime decision,而不是单条 user message。

分类通常包含三条主路径。

普通 prompt 会进入 src/utils/processUserInput/processTextPrompt.ts,被转换成 user message。这里会处理 content blocks、图片、粘贴内容、IDE selection 等,最终形成模型可见的消息。

slash command 会进入 src/utils/processUserInput/processSlashCommand.tsx。命令可能是 prompt command,也可能是 local command 或 local-jsx command。它可以展开为新的 prompt,也可以在本地完成,还可以修改 allowed tools、model、effort 或 messages。

bash mode 会进入 src/utils/processUserInput/processBashCommand.tsx。它把用户输入包装成命令执行语义,调用 shell 工具路径,并把 stdout / stderr 变成可回填的结果。这类输入通常不需要模型先解释,因为用户已经给出明确动作。

可迁移的形状如下:

type InputKind = 'prompt' | 'slash_command' | 'bash_command' | 'meta'

type CompiledInput = {
  kind: InputKind
  messages: RuntimeMessage[]
  shouldQuery: boolean
  controls: {
    allowedTools?: string[]
    model?: string
    effort?: 'low' | 'medium' | 'high'
    nextInput?: string
    submitNextInput?: boolean
  }
  localResult?: string
}

async function compileInput(raw: UserInput, ctx: InputContext): Promise<CompiledInput> {
  const kind = classify(raw, ctx)

  if (kind === 'slash_command') return compileSlashCommand(raw, ctx)
  if (kind === 'bash_command') return compileBashCommand(raw, ctx)
  if (kind === 'meta') return compileMetaPrompt(raw, ctx)

  return compileTextPrompt(raw, ctx)
}

关键是先分类,再决定执行语义。否则 slash command、bash command 和普通 prompt 会混成一种字符串,runtime 就只能让模型猜。

机制二:shouldQuery 是本地执行和模型循环的分界线

Input Pipeline 最重要的字段是 shouldQuery。它回答的问题不是“有没有消息”,而是“这轮输入是否应该进入 Agent Loop”。

在这个机制里,processUserInput() 在 base 处理后会先看 shouldQuery:如果结果是 false,就直接走本地 turn,不再进入 UserPromptSubmit hooks 和模型调用。hook 阻断时也会返回 system warning message,并设置 shouldQuery=false。QueryEngine 拿到结果后,根据 shouldQuery 决定是否调用 query()。如果不进入模型,它仍然可以输出 system/init、回放本地命令消息、返回 success result,并保留 transcript 语义。

这体现了一个成熟 runtime 的边界:conversation turn 不等于 model request。

一个 turn 可以只是本地命令,例如列出命令、打开选择 UI、执行 bash mode、更新模型配置、触发 compact、展示帮助。它仍然属于会话,因为它可能改变状态,可能需要被用户看到,可能影响后续输入。但它不应该消耗模型调用。

async function handleTurn(raw: UserInput, runtime: Runtime) {
  const input = await runtime.input.compile(raw)

  runtime.messages.push(...input.messages)
  await runtime.transcript.recordAcceptedInput(input.messages)

  if (!input.shouldQuery) {
    return runtime.result.local({
      text: input.localResult,
      controls: input.controls,
    })
  }

  return runtime.loop.run({
    messages: runtime.messages,
    allowedTools: input.controls.allowedTools,
    model: input.controls.model,
  })
}

这条分界线的工程价值很大。它让本地控制路径保持确定性,也让模型上下文更干净。用户输入 /model sonnet 不应该让模型“理解我要切模型”;它应该由 runtime 直接切换配置。

机制三:slash command 是扩展总线

Claude Code 的 slash command 不只是快捷命令。src/commands.tssrc/types/command.ts 把命令定义为 runtime extension。命令可以是 prompt、local 或 local-jsx;来源可以是内置命令、skills、bundled skills、plugins、plugin commands、workflows、dynamic skills,甚至 MCP prompt。

这意味着 /xxx 在用户眼里是一个命令,在 runtime 眼里是一段带能力声明的扩展协议。

prompt command 可以把命令展开成模型 prompt,并附带 allowed tools、model、context 或 hook。local command 可以直接执行宿主逻辑,返回文本、compact boundary 或 skip。local-jsx command 可以使用 React/Ink 呈现交互式本地 UI,例如选择器或配置界面。

更重要的是,slash command 的处理发生在进入 Agent Loop 之前。它可以决定这一轮是否调用模型,也可以修改后续模型调用的参数。这让扩展不必伪装成自然语言 prompt。

type SlashCommand =
  | {
      type: 'prompt'
      name: string
      expand(args: string[], ctx: InputContext): Promise<PromptExpansion>
    }
  | {
      type: 'local'
      name: string
      run(args: string[], ctx: InputContext): Promise<LocalCommandResult>
    }
  | {
      type: 'local-jsx'
      name: string
      render(args: string[], ctx: InputContext): LocalView
    }

async function compileSlashCommand(raw: string, ctx: InputContext): Promise<CompiledInput> {
  const parsed = parseSlash(raw)
  const command = ctx.commands.find(parsed.name)

  if (command.type === 'local') {
    const result = await command.run(parsed.args, ctx)
    return { kind: 'slash_command', messages: result.messages, shouldQuery: false, controls: {}, localResult: result.text }
  }

  if (command.type === 'prompt') {
    const expansion = await command.expand(parsed.args, ctx)
    return { kind: 'slash_command', messages: expansion.messages, shouldQuery: true, controls: expansion.controls }
  }

  return renderLocalCommand(command, parsed, ctx)
}

这种设计的启发是:Agent 产品的扩展点不应全部挤进工具系统。工具是模型可调用动作;slash command 是用户可调用的 runtime 控制入口。二者都重要,但边界不同。

机制四:附件和多模态内容被结构化注入

在 coding agent 中,用户输入经常带附件:粘贴文本、图片、IDE selection、文件片段、hook context、nested memory、agent mention。Input Pipeline 的任务不是把这些内容粗暴拼到 prompt 后面,而是保持它们的结构和来源。

src/utils/processUserInput/processUserInput.ts 引入 image validation、image resize、image store、attachment message、pasted content、IDE selection 等处理。图片会经过有效性检查和 resize/downsample;粘贴内容可以用占位符引用再展开;attachment message 可以和 user message 分开,避免把 runtime 注入的上下文误认为用户手写文本。

这种结构化注入至少解决三类问题。

第一是可见性。用户手写文本、系统注入 context、工具结果、memory attachment 应该有不同来源标签。否则后续恢复、审计、UI 展示都会混乱。

第二是预算。图片和大段粘贴内容必须经过大小控制,工具结果和附件也可能需要压缩或去重。Input Pipeline 先保留结构,后续 context projection 才能做预算治理。

第三是安全。桥接消息、meta prompt、hook context、remote input 是否允许触发 slash command,应该由 runtime 判断。skipSlashCommandsbridgeOrigin 这类字段说明,输入来源会影响可执行语义。

type InputAttachment =
  | { source: 'paste'; text: string; label: string }
  | { source: 'image'; mediaType: string; dataRef: string }
  | { source: 'ide_selection'; file?: string; range?: Range; text: string }
  | { source: 'hook'; text: string; visibility: 'model_only' }

function buildMessages(text: string, attachments: InputAttachment[]): RuntimeMessage[] {
  const user = createUserMessage({ text, blocks: visibleBlocks(attachments) })
  const injected = attachments
    .filter(a => a.source === 'hook' || a.source === 'ide_selection')
    .map(a => createAttachmentMessage(a))

  return [user, ...injected]
}

这里的原则是:结构先于拼接。只要来源和结构还在,后续模块就能决定如何投影给模型、如何展示给用户、如何写入 transcript。

机制五:hook context 是输入边界上的治理点

Claude Code 在输入处理阶段执行 UserPromptSubmit hooks。hook 可以产生 progress,可以 blocking error,可以 prevent continuation,也可以追加 additional context。也就是说,hook 不是结果处理器,而是 turn 是否成立之前的治理点。

这说明 hooks 不是“模型回答后的插件”,而是输入边界上的治理点。它们可以在模型看到 prompt 之前做检查、注入上下文或阻止继续。

这种设计适合企业和团队场景。例如:输入触发敏感操作时,hook 可以阻止;当前目录有团队规则时,hook 可以追加 context;远程宿主需要做审计时,hook 可以记录 prompt 提交事件;某些自动化系统可以把隐藏的 meta 信息注入模型,而不污染用户输入框。

async function applySubmitHooks(input: CompiledInput, ctx: InputContext): Promise<CompiledInput> {
  if (!input.shouldQuery) return input

  for await (const result of ctx.hooks.userPromptSubmit(input)) {
    if (result.blockingError) {
      return {
        kind: input.kind,
        messages: [asSystemWarning(result.blockingError)],
        shouldQuery: false,
        controls: input.controls,
      }
    }

    if (result.preventContinuation) {
      input.messages.push(asUserVisibleStop(result.stopReason))
      return { ...input, shouldQuery: false }
    }

    if (result.additionalContext) {
      input.messages.push(asAttachment(result.additionalContext))
    }
  }

  return input
}

hook 的位置很关键。如果放在模型调用后,它只能观察结果;放在 Input Pipeline,它可以改变 turn 是否成立。

机制六:输入编译依赖完整 runtime context

Input Pipeline 并不是无状态函数。ProcessUserInputContext 继承了 ToolUseContext 和 local JSX command context,包含工具列表、commands、MCP clients、AppState getter/setter、permission mode、readFileState、AbortController、file history updater、attribution updater、nested memory triggers、dynamic skill triggers、discovered skills 等。

这看起来很重,但它反映了真实需求。输入命令可能需要读取当前 permission mode;slash command 可能需要修改 messages;skill discovery 需要记录本轮发现了哪些 skill;附件处理可能需要 read file cache;local JSX command 需要设置本地 UI;bash mode 需要 canUseTool 和 abort signal。

所以 Input Pipeline 不能被设计成纯字符串函数。更合理的抽象是“带上下文的编译阶段”。

type InputContext = {
  messages: RuntimeMessage[]
  setMessages(update: (prev: RuntimeMessage[]) => RuntimeMessage[]): void
  tools: ToolRegistry
  commands: CommandRegistry
  permissions: PermissionContext
  appState: AppStateRef
  files: FileStateCache
  abort: AbortController
  hooks: HookRuntime
  source: 'repl' | 'sdk' | 'remote' | 'system'
}

这个上下文越清晰,后续 Agent Loop 就越简单。Loop 不需要知道用户原始输入是否来自 slash command、桥接消息还是 IDE selection;它只接收已经编译好的 messages 与控制字段。

Claude Code 作为 Input Pipeline 案例

从 Agent Runtime 的角度看,Claude Code 的输入层不是 UI 预处理,而是 runtime 编译阶段。普通 prompt、slash command、bash mode、IDE selection、粘贴内容、hooks 和 bridge 消息,都先被整理成 messages 与控制字段,再决定是否进入 Agent Loop。

这也是 Input Pipeline 值得单独讨论的原因:用户输入不是一段字符串,而是一次会改变 turn 路径、工具权限、模型选择和上下文结构的运行时事件。

工程启发

第一,把输入建模为事件,而不是字符串。事件至少包含内容、来源、分类、可见性和控制字段。这样才能支持 prompt、命令、shell、附件、hooks、远程桥接和系统 meta prompt。

第二,在模型调用前建立本地短路路径。shouldQuery=false 是高质量 Agent Runtime 的必要能力。帮助命令、配置命令、bash mode、权限处理、compact、UI 选择都不应该强行走模型。

第三,把 slash command 和 tool 区分开。slash command 是用户控制 runtime 的入口;tool 是模型请求 runtime 执行动作的入口。混在一起会让权限、审计和交互体验都变差。

第四,保留附件结构。不要把图片、粘贴文本、IDE selection、hook context 全部转成一段大 prompt。结构化消息让预算治理、恢复、可见性和安全策略都有落点。

第五,让输入编译依赖显式 context,而不是到处读全局状态。这样同一套输入逻辑才能被 REPL、SDK、远程会话和子 Agent 复用。

小结

Claude Code 的 Input Pipeline 完成的是一次语义编译:从用户可见输入,到 runtime 可执行的 messages、local result 和控制字段。它先判断输入类型,再决定是否进入模型;它让 slash command 成为扩展总线,让 bash mode 成为本地执行路径,让附件和 hook context 以结构化方式进入会话。

这层机制承接第 01 篇的 Agent Runtime:runtime 需要入口边界,Input Pipeline 就是这个边界。下一篇会进入 QueryEngine:当输入已经被编译成 messages 和控制字段之后,Claude Code 如何把它组织成一个可恢复、可审计、可被 SDK 消费的 conversation turn。