今天是前端转 AI 100 天计划的第 13 天。前两天我还在反复练习单个知识点调通一次 API、写好一段 Prompt、估算一下 token。说实话学多了这种零碎的技能心里反而有点虚——每个点单独拿出来都懂但合在一起就不知道能做出什么东西。第 13 天我决定给自己布置一个综合项目做一个命令行 AI 助手 v1把前 12 天学的东西全部串起来。这篇文章不是产品发布会就是一份带源码思路和排坑笔记的实战记录给同样从传统前端往 AI 方向转型的同学做个参考。动手之前我先把目标拆清楚这个命令行 AI 助手要能完成多轮对话、支持流式输出、能切换模型和配置还要有基础的上下文管理。听起来不难但真正把它稳定跑起来牵扯到的东西比想象中多得多。而且我特意没有做 Web 界面——原因后面详细说。先放结论如果你也在学 AI 落地命令行项目是一次性价比极高的综合训练它能让你用最少的环境噪音看见 AI 应用的核心骨架长什么样。1. 为什么第一个综合项目选了命令行而不是 Web 页面1.1 前 12 天做了什么先把我前 12 天的学习主线整理出来方便大家对照自己的进度。我并没有按网上一些“速成路线图”走而是围绕“从零调通一个大模型应用”这条主线铺开的Day 1-2环境准备。Node.js 18、Git、本地模型运行环境先把“模型到底跑在哪”这个问题亲手解决掉。Day 3-4编程基础补强。对前端来说 JavaScript 本身没问题但 Node 的流Stream、事件循环、文件系统操作这些平时不太碰的部分需要专门补。Day 5-6HTTP 与 API 调用。fetch 的基本用法、请求头、状态码处理以及响应体比较大的时候怎么分段读取。Day 7-8Prompt 工程入门。system / user / assistant 三种角色的含义、few-shot 示例的作用、什么时候该要求结构化输出。Day 9-10Token 与上下文。token 估算、上下文窗口限制、内容太长的截断策略。Day 11-12错误处理与重试以及终端交互的基础readline 读输入、ANSI 颜色码。这些知识点单看都不难但它们有一个共同的问题彼此之间没有粘合。如果不做一个把它们全部串起来的东西再过两周我大概率会把它们忘得一干二净。所以 Day 13 不是“学新东西”而是“收口”——把所有碎片焊成一个能用的工具。1.2 三个候选方案的取舍做综合项目最自然的想法是做一个 Web 聊天页面。但我列了一下三个方案的对比结果很清楚候选方案工作量重心对 AI 核心能力的锻炼Web 聊天页面布局、样式、状态管理、接口封装弱大部分时间在写 UI 和调样式命令行工具输入输出、协议解析、上下文管理强直击 AI 应用的核心链路Electron 桌面应用双进程、打包、原生能力不适合当前阶段环境复杂度太高前端老本行做 Web 页面确实半小时就能出活但那恰好违背了这次学习的目的。命令行 AI 助手把 UI 层完全剥掉剩下的全是 AI 应用真正的难点上下文怎么组织、流式数据怎么解析、网络异常怎么兜底、历史记录怎么存。这些能力跟界面形态无关后面做任何形态的 AI 应用——网页、插件、自动化脚本——都要用同样的底层逻辑。1.3 技术栈选型Node.js 的理由我直接选了 Node.js没有切 Python。理由很实际作为前端JavaScript 是母语不需要再花时间适应语法。Node 18 自带全局 fetch调接口零依赖。内置 readline 模块可以做终端交互node:fs 可以读写配置文件。整个项目不装任何第三方包就能跑起来学习成本集中在业务逻辑上。不用 Python 不是说它不好。Python 在 AI 生态里有大量专用工具链但那是 Day 40 之后该考虑的事。现阶段用熟悉的语言先把“AI 应用长什么样”的直觉建立起来比工具链的先进性重要得多。语言只是载体上下文管理和协议解析的思维换语言同样成立。2. 命令行 AI 助手 v1 的整体设计2.1 五层模块划分开工前我画了一张很粗糙的模块图没有用复杂架构就分了五层每层干一件单一的事入口层解析启动参数打印欢迎信息拉起主循环。交互层封装 readline 输入识别普通对话和以斜杠开头的指令。会话层维护消息数组、历史记录、token 估算和裁剪策略。调用层负责调模型接口、解析流式返回、处理重试和超时。输出层处理颜色、简单格式化、结束换行等终端显示细节。这样分层的核心好处是每一层都能单独测试。比如我可以先把调用层的流式解析单独抽出来喂一段假的 SSE 数据验证解析逻辑再接入真实模型。对于刚转型的人来说这种“能单独验证”的模块拆法比硬背设计模式实在得多。2.2 消息结构与上下文管理的底层逻辑对话上下文本质上就是一个消息数组绝大多数兼容 OpenAI 格式的接口都认这个结构[ { role: system, content: 你是一个简洁可靠的命令行助手。 }, { role: user, content: 用一句话解释什么是闭包 }, { role: assistant, content: 闭包是函数与其词法作用域的组合内部函数能访问外部函数变量。 }, { role: user, content: 给一个 JavaScript 的例子 } ]这里有几个细节system 消息通常放最前面用来设定全局行为user 和 assistant 消息要交替出现如果连续两条 user有些服务会报错或忽略后一条。所以每次调用结束后必须把助手这次的完整回复作为 assistant 消息推入数组再让用户输入下一条。这个逻辑听起来平平无奇但很多第一次写多轮对话的人都会漏掉“把回复存回去”这一步。上下文管理的关键是估算 token。精确的 token 数需要看具体模型的分词器但本地估算用启发式就够了function estimateTokens(text) { // 中文按 1.5 token/字估算英文按 4 字符/token 估算 const cjk text.match(/[\u4e00-\u9fff]/g)?.length || 0; const other text.length - cjk; return Math.ceil(cjk * 1.5 other / 4); }这个公式不精确但足够用于判断“我的对话是不是快塞满窗口了”。真实生产环境应该用模型的 tokenizer或者至少用 tiktoken 这类按模型定制的工具但对于 v1 来说做一个不会在长对话中爆掉的安全网更重要。2.3 配置管理密钥、模型与参数的存放策略配置文件放在用户主目录下的隐藏文件夹里读取和写入都走 node:fs。默认配置指向本地模型服务这样不依赖任何云厂商账号开箱即用{ baseURL: http://127.0.0.1:11434/v1, apiKey: ollama, model: qwen2.5:7b, temperature: 0.7 }如果你用云厂商的模型服务只需把 baseURL 换成对应的接口地址并在 apiKey 里填入自己的密钥。本地模型一般要求 apiKey 非空填一个占位字符串即可。配置读取的优先级我做了三级环境变量 配置文件 内置默认值。这样既能用配置文件固定团队默认值又允许个人通过环境变量覆盖不会把密钥写进代码仓库。具体实现就是一层简单的合并逻辑不复杂但它让项目具备了最基本的“环境敏感性”。3. 从空目录到可用的 CLI AI 助手完整实现过程3.1 初始化项目零依赖起步项目结构很简单我追求的是“一个人能看懂全部代码”cli-ai/ ├── index.js // 入口主循环、指令分发 ├── src/ │ ├── config.js // 配置读写 │ ├── session.js // 上下文管理 │ ├── client.js // 模型调用与流式解析 │ └── output.js // 颜色与格式化 └── package.json初始化命令只有两条mkdir cli-ai cd cli-ai npm init -y要求 Node 版本 18 以上。如果你还在用 16fetch 就得装 polyfill建议直接升版本省掉一堆兼容问题。整个项目零第三方依赖重启终端、换电脑都能直接跑这对学习期很重要——环境越简单出问题越容易定位。3.2 核心链路流式输出的协议解析模型接口的流式返回走的是 SSEServer-Sent Events格式按行返回data: {...}这样的数据块最后以data: [DONE]结束。解析这段流的代码是整个项目的核心也是最容易写错的地方。async function streamChat(messages, config, onDelta) { const resp await fetch(${config.baseURL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${config.apiKey}, }, body: JSON.stringify({ model: config.model, messages, stream: true, temperature: config.temperature, }), }); if (!resp.ok) { const errText await resp.text(); throw new Error(HTTP ${resp.status}: ${errText.slice(0, 200)}); } const reader resp.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); // 最后一段可能是半截行留到下一轮处理 buffer lines.pop() ?? ; for (const rawLine of lines) { const line rawLine.trim(); if (!line.startsWith(data:)) continue; const data line.replace(/^data:\s*/, ).trim(); if (data [DONE]) return; try { const json JSON.parse(data); const delta json.choices?.[0]?.delta?.content ?? ; if (delta) onDelta(delta); } catch { // 非 JSON 的数据直接跳过 } } } }这里最容易踩的坑是“半截行”。网络包在传输层会切分一个data: {...}行可能分两次到达如果每次读完直接逐行解析最后半个 JSON 就会被丢弃表现为输出缺字甚至整体乱码。解决方法是把没有换行符结尾的残片留在 buffer 里等下一轮数据来了补齐再处理。这个经验在 Web 端做流式聊天时同样适用属于一次学会、到处可用的典型。3.3 多轮对话与指令系统主循环用 readline 实现每一行输入先判断是不是指令。以斜杠开头的统一走指令分支避免用户想聊“/clear 这个符号是什么意思”时被误判。import { createInterface } from node:readline; const rl createInterface({ input: process.stdin, output: process.stdout }); rl.setPrompt(你 ); rl.prompt(); rl.on(line, async (input) { const text input.trim(); if (!text) return rl.prompt(); if (text.startsWith(/)) { await handleCommand(text); return rl.prompt(); } messages.push({ role: user, content: text }); await chat(); rl.prompt(); });指令集第一天只做了四个/clear清空上下文、/model查看或切换模型、/config打印当前配置、/exit退出并保存历史。指令系统的价值不只是功能它还逼着我思考“哪些操作应该进入用户可见的命令空间”。比如切换模型这个动作如果做成改配置文件再重启体验就很割裂做成/model指令后整个对话过程可以不间断地完成切换。3.4 终端体验打磨颜色、等待态与退出处理终端输出没有浏览器那么华丽但几个小动作能让体验上一个台阶。第一是颜色我定义了一组 ANSI 颜色常量const C { reset: \x1b[0m, dim: \x1b[2m, green: \x1b[32m, cyan: \x1b[36m, yellow: \x1b[33m, red: \x1b[31m, };用户输入用绿色助手输出用白色系统提示用青色错误用红色。这样终端里一堆文字不至于糊成一片。第二是等待态因为流式输出通常有几百毫秒到一两秒的首字延迟在发起请求前打印一个“思考中...”的占位提示收到第一个 delta 后把它清掉否则用户对着空白终端会以为程序卡死了。第三是退出处理监听 CtrlC 时先保存历史再退出避免用户敲了一长串对话因为误触而全部丢失。4. 踩坑实录四类高频问题排查与修复4.1 流式数据粘包与半截行问题这是第一天遇到最多的问题。现象是助手回复偶尔缺字、JSON 解析报错、或者输出突然停住。排查时我把原始字节打印出来发现标准的 SSE 一行常在中间被截断和前文说的一样。修复方案就是 buffer 留尾行每次读到的数据不急着解析先拼到缓冲区里按换行符切分最后一段留到下一轮。这个坑几乎每个写流式输出的开发者都会碰到一次早点踩完早点安心。4.2 上下文窗口溢出导致回复质量骤降本地 7B 模型默认上下文窗口通常几千 token。当历史对话一长要么服务端直接报错要么它把窗口外的内容静默丢弃表现为模型突然“失忆”。我给会话层加了一个裁剪函数function trimContext(messages, maxTokens 4000) { const system messages.filter(m m.role system); let history messages.filter(m m.role ! system); let used system.reduce((sum, m) sum estimateTokens(m.content), 0); const kept []; for (let i history.length - 1; i 0; i--) { const cost estimateTokens(history[i].content); if (used cost maxTokens) break; used cost; kept.unshift(history[i]); } return [...system, ...kept]; }逻辑很简单从最新的消息往回保留一旦超过阈值就停下来老的对话被丢弃system 提示始终保留。这样模型不会因为“记忆”太多旧内容而答非所问。实际使用中我观察到保留最近 10 到 15 轮对话的体验明显好于保留 30 轮以上。4.3 模型服务接口差异导致的隐性失败本地模型和云服务的接口大体兼容但细节差异会让程序“看起来能用却时不时抽风”。比如本地服务的/v1路径必须拼接正确模型名必须以服务端实际拉取的名为准写成聊天中随意起的昵称会直接 404还有部分服务不支持 temperature 参数或对温度范围有严格要求。解决思路是调不通时先扣 curl用原始 HTTP 请求验证接口行为再回过来核对代码。这个习惯帮我省了大量排查时间。4.4 常见问题速查表现象可能原因处理方式输出一半就停住网络中断或服务端提前断流捕获读取异常提示用户重试保留已输出内容追加续接输出乱码或缺字流式解析丢半截行用 buffer 缓存未换行结尾的数据配合 TextDecoder 的 stream 模式401 鉴权失败apiKey 为空或服务端不认检查配置本地模型填任意非空占位字符串长对话后模型“失忆”上下文超出窗口被截断在本地估算 token超过阈值裁剪历史消息中文输入在终端丢失终端编码或 readline 配置问题使用 UTF-8 终端Windows 下优先用 Windows Terminal模型切换后接口 404模型名写错或服务端未拉取先用服务端列表接口核对可用模型名4.5 两个补充的小技巧再说两个不在故障表里、但对体验影响很大的细节。第一请求超时必须有。流式请求如果服务端一直不发数据fetch 默认会一直等用户以为卡死了。我给读取循环加了一个“首字超时”判断超过 30 秒没有收到任何数据就中断并报错。第二退出时要保存完整的对话记录。很多人只把配置做了持久化忘了对话本身也可以存成 JSON 文件。v1 我把历史写到~/.cli-ai/history/日期.json下次启动加一个/load指令就能恢复会话这个功能在调试问题时特别好用。5. 实测效果与后续安排5.1 三个真实场景的实测记录v1 跑通后的第一个晚上我实际用了三个场景来验证场景一是当代码助手。我让它解释 React 的 useEffect 闭包陷阱并给出两个写法的对比。由于我把项目本身的代码片段作为上下文塞进去它的回答明显比不带上下文时具体这说明 session 层的价值确实能直接体现在输出质量上。场景二是做中文翻译。把 system 提示设为翻译助手指定术语表然后连续翻译了几段技术文档。发现一个小问题如果对话历史里混入非翻译类内容模型会自动脱离翻译模式。后来我让系统提示每隔几轮自动重申一次角色效果稳定很多。场景三是整理会议纪要。把一段口语化记录粘进去让它输出结构化摘要。这个场景对 token 消耗最大也让我第一次真实感受到窗口管理的重要原文一长不加裁剪直接超限报错加上裁剪后能跑但摘要的完整性会略降。这说明 v1 的裁剪策略还有优化空间比如按内容重要程度保留而不是简单按时间倒序。5.2 v1 的数据表现记录几个客观数字在本地 7B 模型下首字延迟大约 0.8 到 1.5 秒流式输出稳定后每秒约 15 到 25 个 token在云服务的大模型上首字延迟能压到 0.3 秒以内输出速度明显更快。内存占用方面Node 进程稳定在 60MB 上下加上本地模型服务的内存占用16GB 内存的机器跑起来没压力。这些数字不惊艳但作为第一个自建工具稳定性比速度更重要——我连续跑了几个小时的对话没有出现一次进程崩溃。5.3 v2 及后续 80 多天的规划v1 做完之后我明确列出了 v2 的三件事。第一是支持多会话允许用户开多个对话窗口每个会话独立的上下文按会话名切换第二是接入函数调用让模型可以调用本地 shell 命令或文件读写工具这才是真正的 agent 雏形第三是加一个简单的 RAG 能力让助手能读指定目录下的文档再回答。这三件事分别对应 agent、工具调用、检索增强三个方向正好可以作为接下来 80 多天的学习主线一步一步拆开来做。先说明一下这一篇用的是最传统的“本地服务 通用 HTTP 调用”路线没有踩任何花哨的新框架完全靠 Node 内置能力完成。对刚转型的朋友来说这个路线的好处是每一步都能落地验证不被工具链绑架。等把底层逻辑吃透再上手 LangChain 这类框架时会发现框架只是把你自己动手写过的东西抽象掉了不会产生“看不懂它在干嘛”的焦虑。最后说点个人感受。做 v1 的那天晚上我坐在电脑前用自己写的命令行助手查了几个 React 概念又让它帮我润色了一段周报那种“这个东西是我自己写的”的踏实感比看十篇教程都强。很多人转型 AI 最大的障碍不是缺资料而是缺一个“完成闭环”的瞬间。Day 13 这个综合项目我的目标就是制造这个瞬间。如果让我给一条建议别等基础全学完再动手。把你手头现成的零散技能挑一个能拼成最小闭环的方向先做一个难看的、但能跑的 v1然后再用后面几十天慢慢把它养好看。这比把教程囤进收藏夹有用一万倍。另外分享一个小技巧项目里每一个模块我都写了简短的注释不是为了给别人看而是为了两周后自己回来看时能快速想起来当时为什么这么写。代码会忘注释不会。