1. 为什么要在 CLI 里做提示词调试器Claude Code 提示词工程的核心痛点其实不在写提示词本身而在于对比。你改了一版 system prompt输出好像变好了但到底是哪一句起了作用是加了「先分析再动手」的约束还是把工具优先级从 A 换到 B如果每次都要手动改文件、重启 CLI、重新输入一遍问题调试效率会低到让人放弃优化。我试过最原始的做法把 prompt 存在prompts/v1.txt、prompts/v2.txt然后写个 shell 脚本循环调用 API把结果 diff 出来。问题是输出是纯文本模型返回的 JSON 结构、工具调用意图、思考链全糊在一起肉眼根本分不清差异。更麻烦的是Claude Code 这类 CLI 工具本身是交互式的你没法在它内部直接做 A/B 对比。所以需要一个独立的交互式调试器用 TypeScript 写逻辑用 React Ink 渲染终端 UI左边输入提示词右边实时显示两个模型的输出差异。它不替代 Claude Code而是作为提示词工程的「实验台」——你在调试器里验证好的 prompt 结构再搬回 Claude Code 的配置文件里。这个调试器适合谁三类人一是正在给 Claude Code 写自定义 skill 或 agent 的开发者需要反复调 system prompt二是做 CLI 类 AI 工具的产品团队想建立提示词回归测试流程三是想理解「提示词工程到底在工程什么」的进阶学习者。它不要求你会写 React 前端但需要你能看懂 TypeScript 类型和基本的终端渲染概念。技术选型上React Ink 是关键。它把 React 的组件模型搬到终端里你可以用Box、Text、useInput这些 API 构建带状态、带键盘交互的 CLI 界面。相比直接console.logInk 能做到局部刷新、光标控制、多面板布局——这正是对比调试需要的。而 TypeScript 保证你在改 prompt 结构时参数类型不会悄悄错位。整个调试器的数据流是这样的用户输入一段测试问题 → 调试器把「提示词 A 问题」和「提示词 B 问题」分别发给模型 → 流式接收两边输出 → 在终端左右分栏渲染 → 用户用快捷键切换焦点、滚动、标记差异。模型调用统一走 TaoToken 的 Key 通道这样你不需要在代码里硬编码多个厂商的 endpoint换模型只改一个 Model ID。下面从项目初始化开始一步步把这个调试器搭起来。2. TaoToken 统一 Key 接入前置准备在写代码之前先把模型调用通道打通。调试器需要频繁发请求做对比如果每次都要处理不同厂商的鉴权、base URL、超时重试代码会变得很脏。TaoToken 的作用就是把这些差异收敛成一个 OpenAI 兼容的接口你只需要一个 Key、一个 Base URL、一个 Model ID。先拿 Key。打开 https://taotoken.net/api-keys 登录后创建一个新的 API Key。建议给这个调试器单独建一个 Key命名成prompt-debugger-dev方便后续按项目统计用量。创建后立刻复制页面刷新后就看不到完整 Key 了。拿到 Key 之后确认你的调用地址。TaoToken 的 API Base URL 是https://taotoken.net/api注意这里不要加任何路径后缀OpenAI SDK 会自动拼接/v1/chat/completions。如果你用的是 Anthropic 风格的 SDKBase URL 同样填这个TaoToken 会做协议适配。模型选择上调试器默认用claude-sonnet-4-20250514做对比基准因为 Claude Code 本身基于 Claude 系列提示词行为最接近。你也可以在配置里换成claude-opus-4-20250514做高质量对比或者换成更便宜的模型做快速迭代。Model ID 的完整列表可以在 https://taotoken.net/doc 查到。环境变量建议这样组织放在项目根目录的.env.localTAOTOKEN_API_KEYsk-你的实际key TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_MODEL_Aclaude-sonnet-4-20250514 TAOTOKEN_MODEL_Bclaude-sonnet-4-20250514这里 Model A 和 Model B 默认相同因为我们要对比的是提示词差异不是模型差异。如果你想同时对比模型把 B 改成另一个 Model ID 即可。验证 Key 是否可用用一条 curl 命令curl https://taotoken.net/api/v1/chat/completions \ -H Authorization: Bearer $TAOTOKEN_API_KEY \ -H Content-Type: application/json \ -d { model: claude-sonnet-4-20250514, messages: [{role: user, content: 回复 OK 两个字母}], max_tokens: 10 }如果返回的 JSON 里有choices[0].message.content且内容是OK说明通道正常。如果返回 401检查 Key 是否复制完整、有没有多余空格。如果返回 404检查 Base URL 是不是写成了https://taotoken.net/api/v1——多写/v1会导致路径变成/api/v1/v1/chat/completions。这一步做完你手里就有了三个东西Base URL、API Key、Model ID。后面代码里所有模型调用都复用这三个值不再出现第二个 endpoint。3. 项目初始化与可复制配置现在开始搭项目。用 pnpm 初始化一个 TypeScript React Ink 的 CLI 工程。为什么不用create-react-app因为 Ink 不需要 DOM它跑在 Node 里用 tsup 或 tsc 编译即可。先建目录并初始化mkdir prompt-debugger cd prompt-debugger pnpm init pnpm add react ink inkjs/ui openai dotenv pnpm add -D typescript tsx types/react types/node tsupinkjs/ui提供了现成的TextInput、Spinner、Select组件省得自己写输入框。openai是官方 SDKTaoToken 兼容它的接口。接着写tsconfig.json关键是jsx设为react-jsxmodule设为ESNext{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, jsx: react-jsx, strict: true, esModuleInterop: true, skipLibCheck: true, outDir: dist, rootDir: src, types: [node] }, include: [src] }package.json里加两个脚本dev用 tsx 直接跑build用 tsup 打包{ name: prompt-debugger, type: module, scripts: { dev: tsx src/index.tsx, build: tsup src/index.tsx --format esm --dts } }然后是核心的模型客户端封装。新建src/client.ts把 TaoToken 的三个配置项读进来导出一个chatStream函数import OpenAI from openai; import dotenv/config; const client new OpenAI({ apiKey: process.env.TAOTOKEN_API_KEY!, baseURL: process.env.TAOTOKEN_BASE_URL ?? https://taotoken.net/api, }); export interface ChatOptions { model: string; systemPrompt: string; userInput: string; onDelta: (text: string) void; signal?: AbortSignal; } export async function chatStream(opts: ChatOptions): Promisestring { const stream await client.chat.completions.create( { model: opts.model, stream: true, messages: [ { role: system, content: opts.systemPrompt }, { role: user, content: opts.userInput }, ], }, { signal: opts.signal } ); let full ; for await (const chunk of stream) { const delta chunk.choices[0]?.delta?.content ?? ; if (delta) { full delta; opts.onDelta(delta); } } return full; }注意baseURL直接读环境变量默认值写死https://taotoken.net/api。这样即使.env.local没加载代码也不会崩。stream: true是必须的因为调试器要实时显示输出等完整响应再渲染体验太差。提示词配置单独放一个文件src/prompts.ts方便你快速改export const PROMPT_A 你是一个代码助手。回答时先给出结论再给理由。; export const PROMPT_B 你是一个代码助手。回答时先分析问题背景列出至少两个方案再给出推荐结论。;这两个 prompt 故意设计成结构差异明显方便你第一次跑起来就能看到对比效果。实际使用时替换成你正在调的两版 Claude Code system prompt 即可。到这里项目骨架和配置就齐了。下一步写 React Ink 组件把输入、双栏输出、状态管理串起来。4. React Ink 组件拆分与调试器运行验证组件拆成三个App负责状态和键盘事件PromptPanel负责单侧输出渲染InputBar负责用户输入。先写src/components/PromptPanel.tsximport React from react; import { Box, Text } from ink; interface Props { title: string; content: string; focused: boolean; loading: boolean; } export const PromptPanel: React.FCProps ({ title, content, focused, loading }) { return ( Box flexDirectioncolumn width50% borderStyle{focused ? double : single} borderColor{focused ? cyan : gray} paddingX{1} Text bold color{focused ? cyan : white} {title} {loading ? ... : } /Text Box marginTop{1} Text wrapwrap{content || 等待输出}/Text /Box /Box ); };borderStyle根据焦点切换双线框表示当前滚动焦点。wrapwrap让长文本自动折行不然终端会横向溢出。接着写src/components/InputBar.tsx用inkjs/ui的TextInputimport React from react; import { Box, Text } from ink; import { TextInput } from inkjs/ui; interface Props { value: string; onChange: (v: string) void; onSubmit: (v: string) void; disabled: boolean; } export const InputBar: React.FCProps ({ value, onChange, onSubmit, disabled }) { return ( Box flexDirectioncolumn marginTop{1} Text colorgreen输入测试问题回车发送CtrlC 退出/Text TextInput value{value} onChange{onChange} onSubmit{onSubmit} isDisabled{disabled} placeholder例如解释一下 React 的 useEffect 依赖数组 / /Box ); };然后是主组件src/App.tsx这里逻辑最多。核心状态有四个input存当前输入outputA/outputB存两侧输出loading标记请求中focus标记当前焦点面板。import React, { useState, useCallback } from react; import { Box, useInput, useApp } from ink; import { PromptPanel } from ./components/PromptPanel.js; import { InputBar } from ./components/InputBar.js; import { chatStream } from ./client.js; import { PROMPT_A, PROMPT_B } from ./prompts.js; export const App: React.FC () { const { exit } useApp(); const [input, setInput] useState(); const [outputA, setOutputA] useState(); const [outputB, setOutputB] useState(); const [loading, setLoading] useState(false); const [focus, setFocus] useStateA | B(A); useInput((_, key) { if (key.tab) setFocus((f) (f A ? B : A)); }); const run useCallback(async (question: string) { setLoading(true); setOutputA(); setOutputB(); const modelA process.env.TAOTOKEN_MODEL_A!; const modelB process.env.TAOTOKEN_MODEL_B!; await Promise.all([ chatStream({ model: modelA, systemPrompt: PROMPT_A, userInput: question, onDelta: (d) setOutputA((prev) prev d), }), chatStream({ model: modelB, systemPrompt: PROMPT_B, userInput: question, onDelta: (d) setOutputB((prev) prev d), }), ]); setLoading(false); }, []); return ( Box flexDirectioncolumn padding{1} Box PromptPanel titlePrompt A content{outputA} focused{focus A} loading{loading} / PromptPanel titlePrompt B content{outputB} focused{focus B} loading{loading} / /Box InputBar value{input} onChange{setInput} onSubmit{(v) { if (!v.trim() || loading) return; setInput(); run(v); }} disabled{loading} / /Box ); };最后是入口src/index.tsximport React from react; import { render } from ink; import { App } from ./App.js; render(App /);跑起来pnpm dev终端会出现左右两个面板底部是输入框。输入「用一句话解释闭包」回车。你会看到两侧同时流式输出Prompt A 直接给结论Prompt B 先铺背景再列方案。按 Tab 键焦点框在 A/B 之间切换边框从单线变双线。实测下来从输入到两侧输出完成大约 3-5 秒取决于模型和问题长度。流式渲染让首字延迟降到 1 秒以内对比体验比等完整响应好很多。如果你想保存某次对比结果可以在run函数末尾把outputA、outputB和输入问题写进logs/目录方便后续做回归测试。这个调试器本身不替代 Claude Code它解决的是「提示词改动的效果验证」这一环。你在调试器里确认 Prompt B 的结构确实比 A 好再把 B 的内容搬进 Claude Code 的 skill 配置或CLAUDE.md里。5. 常见报错与排查对照调试器跑起来后最容易撞上的错误集中在模型调用层。下面按真实报错信息逐条对照。401 Unauthorized / invalid_api_key完整报错通常是Error: 401 {error:{message:Invalid API key provided,type:invalid_request_error}}原因有三种Key 没加载进环境变量、Key 复制时带了空格、Key 被删除或过期。排查顺序先在终端echo $TAOTOKEN_API_KEY看有没有值如果没有检查.env.local是否在项目根目录、dotenv/config是否在client.ts顶部导入。注意import dotenv/config必须在new OpenAI()之前执行否则读到的apiKey是undefined。如果环境变量有值但仍 401用第 2 节的 curl 命令单独验证 Key排除代码问题。local proxy failed / connection refusedError: connect ECONNREFUSED 127.0.0.1:7890这是 SDK 读到了系统代理设置。OpenAI SDK 会读HTTP_PROXY、HTTPS_PROXY环境变量。如果你的终端里设过这些变量请求会被转发到本地端口。解决办法是在.env.local里显式清空HTTP_PROXY HTTPS_PROXY或者在client.ts里给 OpenAI 构造参数加httpAgent: undefined。注意不要用任何网络代理工具TaoToken 的地址在国内可直连加代理反而会失败。reading choices / Cannot read properties of undefinedTypeError: Cannot read properties of undefined (reading choices)这个报错说明stream对象是undefined通常是create调用抛异常被吞了。检查chatStream里有没有try/catch把错误吃掉了。另一个常见原因是baseURL写错比如写成https://taotoken.net/api/v1导致请求打到不存在的路径返回 HTML 而不是 JSONSDK 解析失败。确认baseURL就是https://taotoken.net/api不带/v1。OAuth / authentication_error 相关如果你在 Claude Code 本体里看到 OAuth 报错那是 Claude Code 自身的登录态问题和这个调试器无关。调试器走的是 API Key 通道不涉及 OAuth。但如果你想把调试器验证好的配置搬回 Claude Code需要确认 Claude Code 的settings.json里 Base URL 和 Key 配置正确。Claude Code 的配置文件通常在~/.claude/settings.json关键字段是{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的key, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }这三件套——Base URL、Key、Model ID——在调试器和 Claude Code 里必须一致否则你调试器里看到的效果和 Claude Code 里跑出来的不一样。如果你用 Cline 或 CC Switch 这类工具配置项名称可能不同但核心还是这三个值。Cline 的 MCP 配置里baseUrl填https://taotoken.net/apiapiKey填你的 Keymodel填 Model ID。Codex 的auth.json里则是api_base和api_key两个字段。流式输出卡住不刷新如果两侧面板一直显示「等待输出」但 curl 能通检查chatStream里的for await循环有没有被await正确消费。React Ink 的setState在流式高频调用时可能被批处理导致界面不刷新。解决办法是在onDelta里用函数式更新setOutputA((prev) prev d)而不是setOutputA(outputA d)。后者会闭包捕获旧值导致输出丢失。Tab 键不切换焦点useInput的key.tab在某些终端里不触发因为 Tab 被终端用于补全。改用key.ctrl input b作为切换快捷键兼容性更好。或者用key.leftArrow/key.rightArrow做左右切换更符合直觉。6. 从调试器到 Claude Code 的提示词工作流调试器跑通之后真正的价值在于建立一套可重复的提示词迭代流程。我的做法是每次改 Claude Code 的 system prompt先在调试器里用 5-8 个固定测试问题跑一遍 A/B 对比确认新版本在关键场景上不退化再搬回配置文件。固定测试问题建议覆盖这几类简单事实问答测输出效率、多步推理测结构引导、代码生成测格式约束、边界拒绝测安全指令。把这几个问题存在src/fixtures.ts里调试器启动时可以用--suite参数批量跑输出对比报告。如果你需要长期做这件事建议把调试器升级成带历史记录的版本每次运行把promptA、promptB、input、outputA、outputB、timestamp写进 SQLite 或 JSONL 文件。这样你可以回溯「上周三那版 prompt 为什么效果好」而不是靠记忆。对于需要频繁调用模型做批量对比的场景TaoToken 的 Coding Plan 提供了更稳定的配额和更低的单次成本适合把调试器接入 CI 做提示词回归测试。具体方案可以在 https://taotoken.net/coding-plan 查看。模型调用通道的完整文档和 Model ID 列表在 https://taotoken.net/doc 接入过程中如果遇到鉴权或路径问题对照第 5 节的报错表排查。Key 管理在 https://taotoken.net/api-keys 建议给调试器、Claude Code 本体、CI 流程分别建不同的 Key方便按用途统计和吊销。最后留一个实用技巧调试器的PromptPanel组件可以加一个diff模式用diff库把两侧输出按行对比高亮差异行。这样你不需要肉眼逐字比对改一句 prompt 带来的输出变化会直接标红。这个改动只需要在PromptPanel里加一个useMemo计算 diff 结果渲染时用Text colorred标记删除行、Text colorgreen标记新增行。代码量不大但对调试效率的提升很明显。
阅读完成 · 觉得有帮助?