Claude Code 101|02|Input Pipeline
Claude Code 并不是把用户输入原样交给模型,而是先经过 prompt、slash command、bash mode、附件、hook context 等多条路径的归一化。
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.ts 的 submitMessage() 接收 prompt 或 content blocks。输入一旦进入 runtime,就会交给 src/utils/processUserInput/processUserInput.ts 处理。
Input Pipeline 之后才是 QueryEngine 和 Agent Loop。processUserInput() 返回的结果包含 messages、shouldQuery、allowedTools、model、effort、resultText、nextInput、submitNextInput 等字段。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 只是结果之一,旁边还有 shouldQuery、allowedTools、model、effort、resultText、nextInput、submitNextInput。也就是说,输入处理结果是一个 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.ts 和 src/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 判断。skipSlashCommands 与 bridgeOrigin 这类字段说明,输入来源会影响可执行语义。
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。