1. 从浏览器到终端为什么前端转 AI 要先做一个命令行助手做了几年前端日常打交道最多的是 DOM、组件生命周期、构建工具链这些东西。突然转到 AI 方向最大的不适应不是数学而是思维方式——前端讲究的是用户看到什么、点了什么、页面怎么响应而 AI 应用的核心是输入什么文本、模型返回什么、怎么把返回结果编排成有用的输出。这两者之间有一座很好的桥命令行 AI 助手。为什么说命令行是前端转 AI 最合适的第一个综合项目原因很实在。第一它把 UI 层完全剥掉了你不用纠结样式、布局、响应式所有精力都放在请求怎么发、上下文怎么管、结果怎么处理这些 AI 应用的本质问题上。第二命令行工具天然适合做多轮对话管理因为终端本身就是一问一答的交互模式和 LLM 的对话范式高度吻合。第三前 12 天学的东西——API 调用、Prompt 设计、上下文拼接、流式输出、错误处理——全都能在这一个项目里串起来形成一个能跑、能用、能继续迭代的完整作品。这个 v1 版本的定位很明确不是做一个玩具而是做一个你自己每天愿意打开用的工具。它要能记住对话历史、能切换不同的系统提示词、能流式打印模型返回、能在网络抖动时不崩溃、能把对话存下来下次接着聊。这些需求听起来朴素但每一个背后都对应着真实工程里会遇到的坑。我做完这一版之后最大的感受是前 12 天是学零件第 13 天是把零件装成一台能开的车。下面我把整个搭建过程、关键决策和踩过的坑完整拆一遍。2. 动手前的技术选型Python 生态里哪些轮子值得用2.1 语言和运行环境的选择逻辑前端同学第一反应可能是用 Node.js 写毕竟 JS 熟。但我强烈建议这个项目用Python。原因不是 Python 语法更简单而是 AI 领域的官方 SDK、示例代码、社区方案绝大多数都是 Python 优先。你去看主流模型厂商的文档Python 示例永远排第一个Node 示例经常滞后甚至缺失。用 Python 能让你少走很多这个功能 Node SDK 还没支持的弯路。环境上我建议用Python 3.10 以上因为很多新库开始用match语句和新的类型标注语法。安装直接用官网安装包或者系统的包管理器都行关键是装完之后确认python --version和pip --version都能正常输出。Windows 用户特别注意安装时勾选Add Python to PATH否则后面命令行里敲python会提示找不到命令这个坑每年都有无数人踩。提示不要一上来就折腾虚拟环境的各种高级玩法。先用python -m venv venv建一个最基础的虚拟环境激活之后pip install装依赖这就够了。虚拟环境的核心价值是隔离依赖避免不同项目的库版本互相打架。2.2 依赖清单与每个库的存在理由这个项目 v1 版本我刻意控制依赖数量只装真正必要的依赖库作用为什么选它openai调用兼容 OpenAI 协议的模型接口生态最广很多模型服务都兼容这套协议python-dotenv读取.env文件里的密钥配置避免把 API Key 硬编码进代码rich终端里的彩色输出、Markdown 渲染让命令行界面不那么寒酸代码块能高亮prompt_toolkit更好的输入体验支持历史记录、多行输入比裸input()强太多这里重点说rich和prompt_toolkit。很多人觉得命令行工具就该朴素但实际用起来你会发现模型返回的 Markdown 如果原样打印满屏都是**和#读起来非常累。rich能把 Markdown 渲染成带颜色和缩进的终端文本代码块还能语法高亮体验直接上一个档次。prompt_toolkit则解决了input()的两个痛点按上箭头能翻历史输入、支持粘贴多行文本。这两个库加起来不到 5MB但对你每天使用的幸福感提升是巨大的。2.3 密钥管理一个绝对不能偷懒的环节新手最容易犯的错就是把 API Key 直接写在代码里然后一不小心提交到代码仓库。正确做法是建一个.env文件# .env 文件内容示例 API_KEY你的密钥 BASE_URL模型服务的接口地址 MODEL_NAME你要调用的模型名称然后在代码里用python-dotenv读取import os from dotenv import load_dotenv load_dotenv() api_key os.getenv(API_KEY) base_url os.getenv(BASE_URL) model_name os.getenv(MODEL_NAME)同时一定要建一个.gitignore文件把.env写进去。这一步花你 30 秒但能避免未来某天凌晨三点爬起来紧急轮换密钥的惨剧。我自己就见过同事把密钥推到公开仓库结果被人扫到疯狂调用账单直接爆掉。3. 核心骨架搭建把前 12 天的知识点串成一条线3.1 对话循环的设计为什么不能简单 while True最朴素的命令行助手就是一个while True循环读输入、发请求、打印结果。但这样写出来的东西有几个致命问题无法退出只能 CtrlC 强杀、没有历史记录、上下文无限增长导致 token 爆炸。所以对话循环的设计要解决三件事退出机制、历史管理、上下文裁剪。我的做法是用一个列表messages维护完整对话历史每条消息是{role: user/assistant/system, content: ...}的结构。这个结构是 OpenAI 协议的标准格式几乎所有模型都认。循环里每次把用户输入 append 进去发请求时把整个messages传过去拿到回复再 append 回来。退出用特殊命令比如输入/exit或/quit就跳出循环。messages [{role: system, content: system_prompt}] while True: user_input get_user_input() if user_input.strip() in (/exit, /quit): break if not user_input.strip(): continue messages.append({role: user, content: user_input}) reply call_model(messages) messages.append({role: assistant, content: reply})这段代码看起来简单但每一行都有讲究。if not user_input.strip(): continue是防止用户误按回车导致空消息发出去浪费 token。/exit和/quit双命令是照顾不同人的习惯。这些细节不写出来也能跑但用起来就是别扭。3.2 上下文窗口的裁剪策略token 是会烧钱的对话轮数一多messages列表会越来越长每次请求都要把全部历史发过去token 消耗是线性增长的。聊到 50 轮之后你会发现每次请求的费用是前几轮的几十倍而且模型对超长上下文的注意力也会下降。所以必须做上下文裁剪。我的策略是保留 system 提示词 最近 N 轮对话。N 取多少合适实测下来 10 到 15 轮是个平衡点既能保持对话连贯性又不会让 token 失控。实现上很简单def trim_messages(messages, max_turns12): system_msg messages[0] recent messages[1:][-max_turns * 2:] # 每轮包含 user 和 assistant 两条 return [system_msg] recent注意这里max_turns * 2是因为一轮对话包含 user 和 assistant 两条消息。这个裁剪逻辑有个副作用被裁掉的早期对话模型就忘了。如果你需要长期记忆那就要引入向量数据库做检索增强但那是 v2 的事v1 先把基础跑通。提示裁剪的时候千万别把 system 提示词裁掉否则模型的角色设定会丢失回答风格会突然变得很奇怪。我一开始就犯过这个错聊到一半模型突然开始用完全不同的语气说话排查半天才发现是 system 消息被裁了。3.3 流式输出让等待不再煎熬非流式输出最大的问题是模型生成 500 字要等 10 秒这 10 秒里终端一片空白你不知道程序是卡死了还是在正常工作。流式输出能让文字一个字一个字往外蹦体感上快很多。实现上就是把请求参数里的streamTrue打开然后迭代返回的 chunkdef stream_reply(messages): response client.chat.completions.create( modelmodel_name, messagesmessages, streamTrue ) full_reply for chunk in response: delta chunk.choices[0].delta.content if delta: full_reply delta print(delta, end, flushTrue) print() return full_replyflushTrue是关键不加的话 Python 会缓冲输出你看到的还是一坨一坨地蹦而不是逐字。这个参数坑了我一次当时以为是模型服务的问题查了半天才发现是 Python 的输出缓冲。4. 让助手真正好用系统提示词与命令系统的设计4.1 系统提示词不是随便写写很多人把 system prompt 当成一句你是一个有用的助手就完事了这浪费了 system prompt 最大的价值。system prompt 决定了模型的角色、能力边界、输出风格。我给自己这个助手设计了几个预设角色用命令切换通用助手日常问答回答简洁直接代码助手专注编程问题回答带代码块和注释翻译助手中英互译保留原文格式写作助手帮你润色文字输出更书面化切换命令设计成/role code这种形式。实现上就是维护一个角色字典切换时替换messages[0]的内容ROLES { general: 你是一个简洁高效的通用助手回答直接给结论。, code: 你是一个资深程序员回答编程问题时给出可运行的代码和关键注释。, translate: 你是一个专业翻译中英互译时保留原文格式和语气。, write: 你是一个文字编辑帮用户润色文字使其更书面、更流畅。, }这个设计的价值在于同一个模型换个 system prompt 就像换了个专家。你不需要为每个场景单独部署模型只需要切换提示词。这是 LLM 应用开发里性价比最高的技巧之一。4.2 命令系统的解析逻辑命令系统要解决的核心问题是怎么区分用户是在下命令还是在正常聊天。我的约定是所有命令以/开头解析时先判断首字符def handle_command(user_input, state): if not user_input.startswith(/): return None # 不是命令走正常对话 parts user_input[1:].split() cmd parts[0] args parts[1:] if cmd exit or cmd quit: return EXIT elif cmd role and args: state[role] args[0] return f已切换到角色{args[0]} elif cmd clear: state[messages] [state[messages][0]] return 对话历史已清空 elif cmd save and args: save_conversation(state[messages], args[0]) return f已保存到 {args[0]} else: return f未知命令{cmd}这里有个设计细节命令处理函数的返回值如果是字符串就当作系统提示直接打印不发给模型如果是None就走正常对话流程如果是EXIT就退出。这种返回值即指令的模式比用异常或者全局标志位要清晰得多。4.3 对话持久化让助手有记忆命令行工具关掉就没了下次打开又是白纸一张这很反人类。所以要有保存和加载功能。最简单的方案是把messages列表序列化成 JSON 存到文件import json def save_conversation(messages, filename): with open(filename, w, encodingutf-8) as f: json.dump(messages, f, ensure_asciiFalse, indent2) def load_conversation(filename): with open(filename, r, encodingutf-8) as f: return json.load(f)ensure_asciiFalse是必须的否则中文会被转义成\uXXXX的形式文件打开全是乱码。这个参数我第一次写的时候漏了存出来的文件根本没法看。加载功能可以做成启动参数比如python assistant.py --load chat.json启动时自动恢复上次的对话。这样你就能实现今天聊到一半明天接着聊的体验。5. 稳定性打磨错误处理与边界情况5.1 网络请求失败的重试机制调用模型接口最怕的就是网络抖动。一次请求失败就整个程序崩溃那这个工具根本没法日常用。所以必须加重试。我的做法是用指数退避第一次失败等 1 秒重试第二次等 2 秒第三次等 4 秒最多重试 3 次。import time def call_with_retry(func, max_retries3): for attempt in range(max_retries): try: return func() except Exception as e: if attempt max_retries - 1: raise wait 2 ** attempt print(f请求失败{wait} 秒后重试... ({e})) time.sleep(wait)指数退避而不是固定间隔是因为如果服务端真的过载了固定间隔重试只会加剧拥堵而指数退避给了服务端恢复的时间。这是分布式系统里的经典模式用在客户端同样有效。5.2 那些必须处理的边界情况实际用起来会遇到一堆边界情况我列几个最典型的用户输入超长有人会粘贴一整篇文章进来超过模型上下文限制。处理方式是发请求前估算 token 数超了就提示用户或者自动截断。模型返回空内容偶尔模型会返回空字符串这时候不能直接 append 到历史里否则下一轮对话会出问题。流式输出中途断开网络断了full_reply只拿到一半。这时候要决定是丢弃还是保留半截我选择保留并标记回复中断。CtrlC 中断用户按 CtrlC 时不能直接崩要捕获KeyboardInterrupt优雅退出并提示是否保存对话。try: reply stream_reply(messages) except KeyboardInterrupt: print(\n已中断是否保存当前对话(y/n)) # 处理保存逻辑 except Exception as e: print(f出错了{e})这些边界处理加起来可能就几十行代码但正是这几十行决定了你的工具是能跑还是能用。5.3 一个真实的排查案例有一次我发现助手聊到第 8 轮左右就开始失忆明明前面说过的事情它完全不记得。排查过程是这样的先怀疑是模型的问题换了个模型还是一样然后怀疑是裁剪逻辑打印出实际发送的messages一看发现 system 消息确实还在但最近几轮对话的顺序乱了。最后定位到是trim_messages里切片写错了messages[1:][-max_turns*2:]在消息数量不足时会返回空列表导致只发了 system 消息过去。修复方式是在切片前先判断长度def trim_messages(messages, max_turns12): if len(messages) max_turns * 2 1: return messages return [messages[0]] messages[1:][-max_turns * 2:]这个 bug 的教训是切片操作一定要考虑边界情况尤其是负数索引在列表长度不足时的行为很容易出问题。排查时最有效的手段是把中间状态打印出来别靠猜。6. 从 v1 到 v2这个助手还能往哪些方向长v1 跑通之后你会发现它已经能覆盖 80% 的日常使用场景了。但作为一个有追求的开发者你肯定会想继续加东西。我列几个性价比高的扩展方向按实现难度排序第一档改动小收益大加一个/history命令查看当前对话轮数加一个/export把对话导出成 Markdown 文件方便分享。这两个功能加起来不到 20 行代码但用起来很顺手。第二档需要引入新依赖接入本地文件读取让助手能读你指定的代码文件并基于文件内容回答问题。这需要处理文件编码、大小限制、内容截断但实现之后就是一个简易版的代码问答工具。第三档架构升级引入向量数据库做长期记忆。把历史对话向量化存储每次提问时检索最相关的几条历史拼进上下文。这就从记住最近 12 轮升级到记住所有聊过的内容是质的飞跃。但这一步涉及 embedding、向量检索、存储管理工作量不小建议单独作为一个阶段来做。第四档多模型路由根据问题类型自动选择不同的模型。简单问题用便宜的小模型复杂推理用强模型。这需要在请求前做一次意图分类可以用规则也可以用一个小模型来判断。这个方向能显著降低成本但调试起来比较麻烦。我个人建议先把 v1 用上一周记录下哪些地方让你觉得别扭然后针对性地改。不要一上来就追求功能大而全先把核心体验打磨顺滑。工具类项目最大的陷阱就是功能越加越多最后自己都不想用了。7. 前端转 AI 到这个阶段我的一些真实体会做完这个命令行助手回头看前 12 天学的东西最大的感悟是AI 应用开发的门槛不在模型本身而在工程化。模型能力是现成的你调 API 就能用但怎么管理上下文、怎么处理错误、怎么设计交互、怎么控制成本这些才是真正拉开差距的地方。而这些恰恰是前端工程师的强项——我们本来就擅长处理用户交互、状态管理、异常边界。另一个体会是不要被AI这个词吓住。剥开外壳一个 LLM 应用本质上就是拼字符串、发 HTTP 请求、解析响应这三件事的循环。你前 12 天学的 API 调用、Prompt 设计、流式处理已经覆盖了核心。剩下的就是把这些零件用工程思维组装起来而这个能力你做了几年前端早就有了。最后分享一个我踩过的坑一开始我总想着把代码写得优雅各种抽象、各种设计模式往上堆结果一个简单的助手写了 800 行改起来反而费劲。后来我推倒重来用最直白的函数式写法每个功能一个函数总共 200 行出头清晰好维护。工具类项目可读性和可改性比架构优雅重要得多。你写这个助手是为了自己用、为了继续迭代不是为了给别人展示设计能力。能跑、好改、够用就是最好的状态。
阅读完成 · 觉得有帮助?