1. 从零到一:为什么前端转 AI 要做一个命令行助手
前端开发者转 AI 这件事,最怕的就是陷入“教程地狱”——今天学 LangChain,明天看 RAG,后天又去折腾微调,结果一个月下来手里没有一个能跑起来的完整东西。我在第 13 天决定停下来,把前 12 天散落的知识点全部收拢到一个项目里:一个能在终端里对话的命令行 AI 助手。这个选择不是拍脑袋,而是有很实际的考量。
命令行工具是前端工程师最容易上手的“非前端”形态。你不需要画界面、不需要调 CSS、不需要考虑响应式布局,只需要处理输入输出和 API 调用。但恰恰是这种极简形态,逼着你去面对 AI 应用最核心的几个问题:怎么管理对话历史、怎么控制 token 消耗、怎么处理流式输出、怎么做错误重试。这些能力一旦在命令行里跑通了,搬到 Web 端只是换个壳的事。
这个 v1 版本的目标很明确:整合前 12 天学到的 LLM 调用、Prompt 设计、上下文管理、流式响应、错误处理这五块内容,做成一个能日常使用的工具。它要能记住对话上下文、支持多轮交互、能切换不同的系统提示词、能在网络抖动时自动重试、还要把对话记录持久化到本地。听起来功能不少,但拆开看,每一个都是前 12 天单独练过的东西,现在只是把它们串起来。
适合谁来参考这篇内容?如果你是有前端基础、正在往 AI 方向转的开发者,这篇会特别对路,因为我会用前端熟悉的思维去类比后端和 AI 的概念。如果你是完全零基础但想了解一个 AI 助手是怎么从零搭起来的,也能看懂,因为我会把每个决策的理由讲清楚。代码用 Node.js 写,前端同学看起来毫无压力。
提示:这个项目不依赖任何重型框架,核心依赖只有官方 SDK 和一个命令行交互库。我刻意不用 LangChain 这类封装,因为初学阶段自己手写一遍调用链路,比调框架 API 理解深得多。
2. 整体架构设计与技术选型拆解
2.1 为什么是 Node.js 而不是 Python
前端转 AI 最大的纠结就是语言选择。Python 生态确实更丰富,但 Node.js 对前端来说有天然优势:不用切换心智模型,npm 包管理你已经烂熟于心,异步编程的思维可以直接复用。而且现在主流大模型厂商都提供官方 Node SDK,功能上并不比 Python 版少。
我实测下来,用 Node.js 做命令行 AI 助手,开发效率比重新学 Python 高出一大截。你不需要配虚拟环境、不需要纠结 pip 和 conda、不需要处理 Python 版本兼容问题。一个npm install就搞定所有依赖,这对前端来说太熟悉了。
当然 Node.js 也有短板,比如做数据处理和模型微调时生态弱一些。但 v1 阶段我们只做 API 调用和对话管理,这些 Node 完全够用。等后面要做本地模型推理或者复杂的数据管道,再考虑引入 Python 也不迟。
2.2 核心模块划分
整个助手拆成五个模块,每个模块职责单一,方便单独测试和替换:
| 模块 | 职责 | 对应前 12 天的知识点 |
|---|---|---|
| 配置层 | 管理 API Key、模型参数、系统提示词 | Day 2 环境变量与密钥管理 |
| 对话管理层 | 维护消息历史、控制上下文长度 | Day 5 上下文窗口与 token 计算 |
| LLM 调用层 | 封装 API 请求、处理流式响应 | Day 3 首次 API 调用、Day 7 流式输出 |
| 交互层 | 读取用户输入、渲染输出 | Day 4 命令行交互基础 |
| 持久化层 | 保存和加载对话记录 | Day 9 本地存储与文件操作 |
这种分层的好处是,每一层都可以独立替换。比如你以后想把 LLM 调用层从某家厂商换成另一家,只要接口保持一致,上层代码一行都不用改。这就是前端熟悉的“面向接口编程”思路。
2.3 技术选型背后的取舍
命令行交互库我选了@inquirer/prompts,而不是更底层的readline。原因很简单:readline需要自己处理光标、历史记录、多行输入这些细节,写起来很烦。@inquirer/prompts开箱即用,还支持输入校验和自动补全,省下来的时间可以花在 AI 逻辑上。
流式输出这块,我用的是 SDK 自带的 stream 能力,而不是自己手动处理 SSE。手动解析 SSE 虽然能加深理解,但容易在边界情况上翻车,比如数据包被截断、多字节字符被切开。SDK 已经帮你处理好了这些坑,v1 阶段没必要重复造轮子。
持久化我选了 JSON 文件而不是数据库。对话记录这种数据量小、结构简单的场景,用 SQLite 属于杀鸡用牛刀。JSON 文件可以直接用编辑器打开查看,调试的时候特别方便。等对话量大了、需要全文搜索了,再迁移到 SQLite 也不迟。
注意:API Key 绝对不能硬编码在代码里,也不能提交到 Git 仓库。我用的是
.env文件加dotenv库,并且把.env加进了.gitignore。这个习惯从第一天就要养成,我见过太多人因为把 Key 推到公开仓库导致被盗刷的案例。
3. 核心细节解析与实操要点
3.1 对话历史管理:token 预算怎么算
对话历史是 AI 助手的记忆,但记忆不是越多越好。每次请求都要把历史消息一起发给模型,历史越长,消耗的 token 越多,成本越高,响应越慢。更关键的是,模型有上下文窗口上限,超了就直接报错。
我的策略是给历史消息设一个 token 预算,比如 3000 token。每次发请求前,从最新的消息往前累加,加到预算用完为止,更早的消息就丢弃。这样既保留了最近的上下文,又不会超限。
token 的估算不需要精确到个位,用字符数除以 4 是个够用的近似值。英文大概 4 个字符一个 token,中文大概 1.5 到 2 个字符一个 token。我写了个简单的估算函数:
function estimateTokens(text) { // 中文按 1.5 字符/token,英文按 4 字符/token 粗略估算 const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const otherChars = text.length - chineseChars; return Math.ceil(chineseChars / 1.5 + otherChars / 4); }这个估算不精确,但足够用来做预算控制。真正精确的 token 计算需要用对应模型的 tokenizer,那个开销太大,v1 阶段没必要。
3.2 系统提示词的设计:助手的“人设”
系统提示词决定了助手的行为风格。我设计了一个可切换的系统提示词机制,内置几套预设,也支持用户自定义。预设包括“通用助手”“代码助手”“翻译助手”三个。
代码助手的系统提示词是这样的:
你是一个资深编程助手,擅长 JavaScript、TypeScript 和 Node.js。 回答时优先给出可运行的代码示例,代码要加注释。 遇到不确定的问题,明确说明不确定,不要编造 API。 解释概念时用类比,让初学者也能听懂。这段提示词里每句话都有用意。“优先给出代码示例”是引导输出格式,“不确定就说明”是抑制幻觉,“用类比”是控制表达风格。系统提示词不是随便写一句话就行,它是在给模型设定行为边界。
我踩过的一个坑是:系统提示词写得太长太细,反而让模型变得死板。后来我精简到 3 到 5 句话,只保留最核心的行为约束,效果反而更好。提示词工程不是越长越好,而是要精准。
3.3 流式输出的处理:让等待不再焦虑
非流式输出要等模型生成完整回复才返回,长回复可能要等十几秒,体验很差。流式输出是边生成边显示,用户看到文字一个个蹦出来,感知上的等待时间大大缩短。
实现流式输出的关键是处理 chunk。SDK 返回的是一个异步迭代器,每个 chunk 包含一小段文本。你要做的是把 chunk 里的文本提取出来,立即写到终端,而不是等全部收集完再输出。
async function streamChat(messages, onChunk) { const stream = await client.chat.completions.create({ model: 'gpt-4o-mini', messages, stream: true, }); let fullContent = ''; for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content || ''; if (delta) { fullContent += delta; onChunk(delta); } } return fullContent; }这里有个细节要注意:不是每个 chunk 都有 content。有些 chunk 只包含角色信息或者结束标记,delta.content可能是 undefined。所以要用可选链加默认值,避免报错。
还有一个坑是中文乱码。如果 chunk 恰好把一个中文字符的字节切开了,直接输出会显示乱码。SDK 一般会处理好这个问题,但如果你自己手动解析 SSE,就要用TextDecoder并设置{ stream: true }来正确处理多字节字符。
3.4 错误重试:网络抖动不该让对话中断
调用 API 时网络抖动、限流、服务端临时故障都是常态。如果不做重试,用户正聊到一半突然报错,体验很差。我的重试策略是指数退避:第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。
但不是所有错误都值得重试。网络超时、429 限流、500 服务端错误可以重试;401 认证失败、400 参数错误重试也没用,直接报错给用户更合适。
async function withRetry(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (err) { const retryable = [429, 500, 502, 503].includes(err.status) || err.code === 'ETIMEDOUT'; if (!retryable || i === maxRetries - 1) throw err; const delay = Math.pow(2, i) * 1000; console.log(`请求失败,${delay}ms 后重试...`); await new Promise(r => setTimeout(r, delay)); } } }指数退避的意义在于,如果是服务端过载,立即重试只会加重负担,等一会儿再试成功率更高。这个模式在前端请求封装里也很常见,思路是通用的。
4. 完整实操流程与关键环节实现
4.1 项目初始化与依赖安装
先建目录、初始化 npm 项目:
mkdir cli-ai-assistant && cd cli-ai-assistant npm init -y npm install openai @inquirer/prompts dotenv chalk四个依赖各有用途:openai是官方 SDK,@inquirer/prompts处理交互,dotenv读环境变量,chalk给终端输出加颜色。chalk不是必须的,但加上颜色后,用户输入和 AI 回复能明显区分开,体验好很多。
然后在项目根目录建.env文件:
OPENAI_API_KEY=你的密钥 OPENAI_BASE_URL=https://api.openai.com/v1 DEFAULT_MODEL=gpt-4o-miniOPENAI_BASE_URL单独抽出来是为了方便切换服务端点。有些兼容接口的第三方服务只需要改这个地址,代码不用动。
4.2 配置层实现
配置层负责把环境变量读进来,并提供默认值:
import 'dotenv/config'; export const config = { apiKey: process.env.OPENAI_API_KEY, baseURL: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', model: process.env.DEFAULT_MODEL || 'gpt-4o-mini', maxHistoryTokens: 3000, maxRetries: 3, }; if (!config.apiKey) { console.error('缺少 OPENAI_API_KEY,请检查 .env 文件'); process.exit(1); }启动时就检查 Key 是否存在,比等到第一次请求才报错要好。快速失败是命令行工具的重要原则,用户不该等半天才发现配置错了。
4.3 对话管理层实现
对话管理层的核心是一个消息数组,加上 token 预算控制:
export class Conversation { constructor(systemPrompt) { this.systemPrompt = systemPrompt; this.messages = []; } addUser(content) { this.messages.push({ role: 'user', content }); } addAssistant(content) { this.messages.push({ role: 'assistant', content }); } buildPayload() { const payload = [{ role: 'system', content: this.systemPrompt }]; let budget = config.maxHistoryTokens; const reversed = [...this.messages].reverse(); const kept = []; for (const msg of reversed) { const cost = estimateTokens(msg.content); if (budget - cost < 0) break; budget -= cost; kept.unshift(msg); } return [...payload, ...kept]; } }这里从后往前遍历是关键。最新的消息最重要,必须保留;最老的消息最先被丢弃。如果从前往后遍历,可能把最新的消息挤掉了,那就本末倒置了。
4.4 主循环与交互实现
主循环负责读取输入、调用模型、显示回复,一直循环到用户退出:
import { input } from '@inquirer/prompts'; import chalk from 'chalk'; async function main() { const conversation = new Conversation(SYSTEM_PROMPTS.general); console.log(chalk.cyan('AI 助手已启动,输入 /exit 退出,/clear 清空历史')); while (true) { const userInput = await input({ message: chalk.green('你:') }); if (userInput === '/exit') break; if (userInput === '/clear') { conversation.messages = []; console.log(chalk.yellow('历史已清空')); continue; } conversation.addUser(userInput); process.stdout.write(chalk.blue('AI: ')); const reply = await withRetry(() => streamChat(conversation.buildPayload(), chunk => { process.stdout.write(chunk); }) ); process.stdout.write('\n'); conversation.addAssistant(reply); } }注意process.stdout.write和console.log的区别。流式输出时要用write,因为它不自动换行,chunk 才能连续拼接。如果用console.log,每个 chunk 都会换行,输出就乱了。
4.5 持久化实现
对话记录保存成 JSON 文件,文件名带时间戳:
import { writeFile, readFile } from 'fs/promises'; export async function saveConversation(conversation) { const filename = `history/${Date.now()}.json`; await writeFile(filename, JSON.stringify({ systemPrompt: conversation.systemPrompt, messages: conversation.messages, savedAt: new Date().toISOString(), }, null, 2)); return filename; }保存时把系统提示词也存进去,这样加载历史时能还原完整的对话上下文。null, 2是为了格式化输出,方便人眼查看。虽然会占多一点空间,但调试时的便利性值得。
5. 常见问题与排查技巧实录
5.1 常见报错速查表
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
| 401 Unauthorized | API Key 错误或过期 | 检查 .env 里的 Key,确认没有多余空格 |
| 429 Too Many Requests | 请求频率超限 | 加大重试间隔,或降低请求频率 |
| context_length_exceeded | 上下文超模型上限 | 调小 maxHistoryTokens,或清空历史 |
| ETIMEDOUT | 网络超时 | 检查网络,重试机制会自动处理 |
| Cannot find module | 依赖没装 | 重新执行 npm install |
5.2 流式输出卡顿的排查
有次我发现流式输出一顿一顿的,不是平滑地逐字显示。排查后发现是终端缓冲的问题。Node.js 的 stdout 在某些终端下会缓冲输出,导致 chunk 攒一批才显示。
解决办法是在启动时设置:
process.stdout.setDefaultEncoding('utf8');如果还不行,可以在每个 chunk 后强制刷新。不过大多数现代终端不需要这一步,遇到再处理。
5.3 中文输入乱码的处理
在 Windows 的某些终端下,中文输入会乱码。这通常是终端编码问题,不是代码问题。解决办法是把终端编码切到 UTF-8,或者在代码里显式设置输入流的编码。
我实测下来,Windows Terminal 和 VS Code 内置终端都没问题,老版的 cmd 容易出问题。如果遇到,换个终端比改代码省事。
5.4 对话历史丢失的排查
有次用户反馈重启后历史没了。查下来是保存路径用了相对路径,从不同目录启动时保存到了不同位置。改成基于import.meta.url的绝对路径后就稳定了。
import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const historyDir = join(__dirname, '..', 'history');这个坑在 ESM 模块里特别常见,因为 ESM 没有__dirname这个变量,要自己构造。前端同学从 CommonJS 转 ESM 时容易在这里翻车。
5.5 成本控制的实操心得
用gpt-4o-mini这类小模型做日常对话,成本极低,但如果不控制历史长度,token 消耗会悄悄涨上去。我的做法是:日常闲聊用 3000 token 预算,代码讨论用 6000,因为代码上下文更重要。
另外,系统提示词每次请求都会带上,如果写得很长,每次都在烧钱。所以系统提示词要精简,能一句话说清就别用三句。我见过有人系统提示词写了 2000 字,每次请求光系统提示词就花掉不少 token,长期下来是笔不小的开销。
提示:可以在代码里加一个 token 消耗统计,每次请求后打印本次消耗和累计消耗。看着数字涨,你会自然而然地优化提示词和历史管理。
6. 这个 v1 还能怎么继续打磨
做到这里,一个能用的命令行 AI 助手就成型了。它能多轮对话、能流式输出、能重试、能持久化、能切换人设。前 12 天学的东西基本都用上了,而且是在一个真实可用的项目里用上的,不是练习题。
我个人的体会是,把零散知识点串成一个完整项目,理解深度会有一个跃升。单独学“怎么调 API”和“怎么管理上下文”时,你觉得都懂了,但真把它们拼在一起,才会发现模块之间的耦合、边界情况的处理、用户体验的细节,这些是分开学永远碰不到的。
后面可以继续扩展的方向不少:加一个/save命令手动保存当前对话、加一个/load命令加载历史对话、支持把对话导出成 Markdown、接入本地模型做离线模式、加一个简单的插件机制让助手能调用外部工具。每一个扩展都是一个新的学习点,但基础骨架已经搭好了,往上加东西不会推倒重来。
最后分享一个小技巧:调试 AI 应用时,把每次请求的完整 payload 打印到日志文件里。出问题时翻日志,能一眼看出是提示词的问题、历史管理的问题还是模型本身的问题。这个习惯帮我省了大量排查时间,比在代码里到处加 console.log 高效得多。