前端转 AI 的进度条走到第 13 天,我给自己布置了一个稍微硬核的任务:把前 12 天学到的所有零散技能,整合成一个真正能用的命令行 AI 助手 v1。你可能会觉得奇怪,前端转 AI 不是应该先学 PyTorch、学 Transformer 吗?怎么跑到命令行工具上来了?
我有我的理由。前 12 天我学了 Python 基础语法、HTTP 请求、JSON 处理、环境变量管理、函数封装、异常处理,甚至还有一点正则表达式……单独拿出来每一项都不难,但课程式学习有个致命的错觉:你会调 API 不等于你能做出一个产品。中间缺的恰恰是一个把所有知识点串成一条完整链路的综合项目。命令行 AI 助手就是这个项目。
为什么选它?因为它足够小,小到一天能做出来;又足够完整,完整到能覆盖一个"产品级工具"该有的所有环节——配置管理、上下文组装、外部 API 调用、流式解析、数据持久化、异常处理。做完它,你写的代码就不再是"练习作业",而是一个你自己每天都在用的工具。
这篇文章送给正在从前端转 AI 的朋友,或者任何想搞懂"一个命令行 AI 工具到底是怎么从零长出来"的人。我会把 Day 13 的完整思路、代码、踩坑全部摊开来讲,尤其是那些前端思维在 Python 世界里撞墙的瞬间,我会单独开一章细说。
1. 为什么我把第 13 天押在"命令行助手"上
1.1 前 12 天到底学了什么
让我先列一下前 12 天的学习清单,不是为了凑篇幅,而是为了让你看清楚"综合项目"到底在整合什么:
- Day 1-3:Python 基础语法。变量、类型、循环、函数、列表和字典。前端同学上手 Python 其实很快,因为很多概念和 JavaScript 是相通的,但也有一些地方会拧巴,比如缩进代替花括号、没有 var/let 的声明体系。
- Day 4-5:文件读写与 JSON。json.loads / json.dumps 来回折腾,一开始我总觉得 Python 的 JSON 处理和 JSON.stringify 不太一样,其实只是换了个名字。
- Day 6-7:HTTP 请求与 API 调用。用 requests 库打各种公开接口,GET、POST、Header 拼接、参数传递。
- Day 8-9:环境变量与配置管理。用 python-dotenv 加载 .env 文件,理解为什么要用环境变量而不是把密钥硬编码进代码。
- Day 10-11:异常处理与日志。try/except、traceback 打印、把错误信息合理地呈现给用户。
- Day 12:OpenAI 兼容接口的请求格式。搞懂了 messages 数组的结构:system、user、assistant 三种角色,以及 temperature、max_tokens 这些参数的含义。
列完这份清单我自己都吓了一跳:这些东西单独看,每一个都像一个"API 拼图碎片",但拼起来就是一个完整的可运行程序。Day 13 的任务就是把碎片拼成机器。
我见过太多前端转 AI 的朋友,学着学着就停在"会用 requests 调接口"这一步。他们会写一个 demo,打印出模型返回的 JSON,然后觉得自己已经入门了。但实际上,一个真实产品要处理的问题远比 demo 多:密钥放哪里?上下文怎么管理?如果返回很长,用户怎么等?昨天聊到一半,今天怎么继续聊?这些事,只有做一个完整项目才会逼你去想。
1.2 命令行助手为什么是最合适的第一个综合项目
选择命令行而不是做一个 Web 页面,其实是一个刻意的决定。
前端同学的第一反应往往是"做一个聊天网页比较亲切"——毕竟我们有 React、有组件、有 CSS。但恰恰因为前端是我们的舒适区,才应该反向操作,把精力全部砸在"AI 工具本身的核心逻辑"上,而不是 UI 上。
命令行有四个优势:
- 交互模型最简单。终端里只有一个循环:读输入、调 API、打输出。没有路由、没有状态管理、没有组件生命周期。
- AI 的天然载体。ChatGPT 这类产品的原生形态就是对话,而终端天然适合这种密集的文本交互。CLI 工具甚至比 Web 页面更接近"工具"的本质。
- 便于扩展成 Agent。后续想加工具调用、文件读取、自动执行命令,在终端环境下都顺理成章。很多真实的 AI 编程助手最早都是从 CLI 长出来的。
- 逻辑大于样式。这个项目的难点全在数据流和边界条件上,正是在这些地方,前端思维和 Python 工程思维才会发生碰撞,才有最大的学习价值。
1.3 Day 13 的项目边界:做减法比做加法更难
一个综合项目最容易犯的错,就是想一口气把所有东西都塞进去。我第一天动工时列了一个很长的待办清单:多模型切换、函数调用、RAG 知识库、语音输入……然后我一件一件把它们划掉了。
v1 只做四件事:
- 支持通过 OpenAI 兼容接口调用任意大模型(换模型只改配置)
- 多轮对话,能在聊天过程中记住历史
- 流式输出,让回复逐字打印出来而不是一次性砸到屏幕上
- 本地持久化,退出后下次还能接着聊
这四件事分别对应当前技术栈里最核心的四块能力:HTTP 请求与 API 格式、上下文组装、数据流解析、文件读写。至于多模型切换、命令系统(/clear 之类的)、工具调用,我全部留到 v2。原因很简单:v1 的设计目标是"把前 12 天串起来",不是"做一个惊艳的产品"。项目越克制,完成度才越高。
2. 开工前先画图纸:模块划分、数据流与选型
2.1 一条流水线:数据是怎么流动的
我虽然是个前端出身的人,但设计这个项目时没有一上来就写代码,而是先画了数据流图(在纸上画的,没上工具)。整个项目本质上是一条单向流水线:
用户在终端输入一段文本 -> 程序把这段文本和自己的配置合并成 messages 数组 -> 带着 API Key 请求模型接口 -> 接口以 SSE 流式返回文本片段 -> 程序逐段接收并渲染到终端 -> 渲染完毕把整段回复追加进历史 JSON 文件 -> 回到等待输入状态
这条流水线最妙的地方在于:每一步对应一个独立模块,模块之间只通过函数参数和返回值传数据,不共享任何全局状态。这样的话,哪怕某天你想把终端界面换成 Web UI,只需要替换入口和渲染两部分,API 调用和上下文管理完全不用动。
2.2 为什么用 Python 而不是继续用 Node.js
我知道你肯定想问:你都是前端了,为什么不用 Node.js 写这个工具?用 TypeScript 不香吗?
用 Node 当然可以实现同样的功能,甚至在前端手里可能写得更顺手。但我做这个项目的意图不是"写一个只给自己用的脚本",而是"以这个项目为跳板,彻底熟悉 AI 工程方向的 Python 生态"。原因有三个:
- AI 生态的主要接口和示例代码都优先 Python。不管是 HuggingFace、LangChain、LlamaIndex,还是各种推理框架的官方示例,Python 含量远高于 Node.js。想深入 AI 行业,Python 是绕不开的。
- Python 的文本处理和文件操作非常直接,写这种工具类项目心智负担小。
- 前端转 AI 的典型路径是"前端 -> Python -> 机器学习",命令行助手作为 Python 的实战练手,正好完成第一步的跨越。
不过如果你想用 Node.js 复刻这个项目,也没问题——requests 对应 fetch/axios,dotenv 对应 dotenv 包,JSON 文件操作用 fs 模块,SSE 解析用 fetch 的 ReadableStream。技术上完全等价,只是语言环境的差异。
2.3 目录结构与配置体系
项目我放在 ~/ai-assistant(也可以叫 aicli,随你喜欢),最终目录长这样:
ai-assistant/ ├── main.py # 入口:交互循环 ├── config.py # 配置加载 ├── api_client.py # API 请求与流式解析 ├── history.py # 对话历史读写与截断 ├── requirements.txt └── .env # 密钥与模型参数(不进 git)只有四个源文件,每个文件不超过 150 行。为什么这么小?因为我刻意控制每个模块的职责单一:config.py 只干配置的事,api_client.py 只干网络的事,history.py 只干持久化的事,main.py 只干交互的事。这样即使代码量不大,结构也是清晰的、可扩展的。
requirements.txt 里只有三个依赖:
openai>=1.0.0 python-dotenv>=1.0.0 rich>=13.0.0等一下,为什么用 openai 库而不是直接 requests?这个问题我后面会在流式输出那一节细说,先记住结论:openai 官方 SDK 已经封装好了 SSE 解析和重试逻辑,比手写 requests 流式解析要省事得多。rich 库是用来在终端里做彩色输出的,属于锦上添花,不用也行,但用了之后体验会好很多。
.env 配置文件长这样:
OPENAI_API_KEY=sk-xxxx BASE_URL=https://your-api-endpoint MODEL_NAME=gpt-4o-mini TEMPERATURE=0.7 MAX_TOKENS=2048 HISTORY_FILE=.ai_history.json注意 BASE_URL 这一项。现在很多模型服务都提供 OpenAI 兼容接口,只要你把 BASE_URL 换成对应的地址、把 MODEL_NAME 换成对应的模型名,代码完全不用改。这也是我把 API 调用封装在 api_client.py 里的原因——换模型不换逻辑。
3. 第一块拼图:配置管理与上下文组装
3.1 用 python-dotenv 把密钥从代码里剥离
前端开发里我们会用环境变量存 API Key,原理大家都知道,但很多人到了 Python 里反而疏忽了——直接把 Key 写在代码里,然后到处 git push。第 8 天的内容正好解决这个问题。
config.py 的完整实现:
import os from dotenv import load_dotenv load_dotenv() OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") BASE_URL = os.getenv("BASE_URL", "https://api.openai.com/v1") MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o-mini") TEMPERATURE = float(os.getenv("TEMPERATURE", "0.7")) MAX_TOKENS = int(os.getenv("MAX_TOKENS", "2048")) HISTORY_FILE = os.getenv("HISTORY_FILE", ".ai_history.json")load_dotenv() 会自动读取项目根目录下的 .env 文件,把里面的键值对塞进 os.environ。之后用 os.getenv(key, default) 取值,第二个参数是默认值,这样即使某个人 clone 后忘了配置 .env,程序也不会直接崩掉,只是部分参数会用默认值。
这里有一个我在前端里特别熟悉的"默认值"习惯的对标:JS 里是 const key = process.env.KEY ?? 'default',Python 里是 os.getenv("KEY", "default"),本质一样,只是写法不同。但 Python 的 float() 和 int() 转换要特别注意:如果 .env 里 TEMPERATURE 写成了非数字,程序会在启动时立刻抛 ValueError,这个行为其实是好事,它把错误暴露在启动阶段而不是运行到一半,省得用户聊了十分钟才突然崩掉。
3.2 对话历史存哪里:JSON 文件方案的取舍
v1 的对话历史方案非常简单:把 messages 数组直接序列化成 JSON,写入本地文件。history.py 的核心逻辑就三个函数:
import json from pathlib import Path def load_history(file_path): path = Path(file_path) if not path.exists(): return [] with open(path, "r", encoding="utf-8") as f: return json.load(f) def save_history(file_path, history): with open(file_path, "w", encoding="utf-8") as f: json.dump(history, f, ensure_ascii=False, indent=2) def append_message(file_path, role, content): history = load_history(file_path) history.append({"role": role, "content": content}) save_history(file_path, history)前端同学看到这里应该很亲切:这不就是 localStorage 的 JSON 版本吗?区别在于,localStorage 是浏览器替你管理文件,这里你需要自己处理文件存在与否、编码、缩进等问题。json.dump 里的 ensure_ascii=False 参数必须写,否则下次存入的中文会被转成 \uXXXX 的转义序列,虽然也能读回来,但直接打开文件时你会怀疑人生。
为什么 v1 不用 SQLite 或者向量数据库?因为还没到那个复杂度。一天的项目,JSON 文件完全可以支撑几百轮对话。等到对话数量级变大、需要按时间检索的时候,再迁移到 SQLite 不迟。做综合项目最忌讳引入一个当前问题用不到的技术,那叫炫技,不叫工程。
3.3 token 上限与截断策略:前端数组思维在这里不成立
这是 Day 13 里让我最受震撼的地方,值得单独拿出来讲。
前端处理"最多显示 10 条记录",你大概率会写:
const recent = list.slice(-10);这没毛病,因为每条记录的大小差不多,按条数截断是合理的。但对话上下文不一样:每条消息的 token 数量可能差异巨大,用户抛进来一篇 5000 字的文章,和用户发一句"你好",占的 token 完全不是一个量级。如果按条数截断,很容易出现"最近 10 条消息加起来超过模型上下文窗口"的情况,API 直接报错。
正确的截断姿势是按 token 估算来截。v1 里我引入了一个非常简单的估算规则:中文大约 1 个 token 对应 0.6 到 1 个汉字(各家模型略有差异),英文大约 4 个字符对应 1 个 token。我用了一个保守系数来估算每条消息的 token 数,然后从最旧到最新依次丢弃消息,直到总 token 数小于一个安全阈值:
MAX_CONTEXT_TOKENS = 6000 def estimate_tokens(text): # 粗略估算:中文按 1 字符 0.7 token,英文按 4 字符 1 token chinese_chars = sum(1 for c in text if "\u4e00" <= c <= "\u9fff") other_chars = len(text) - chinese_chars return int(chinese_chars * 0.7 + other_chars * 0.25) + 10 def trim_history(history, max_tokens=MAX_CONTEXT_TOKENS): if not history: return history system_msg = None if history[0]["role"] == "system": system_msg = history[0] history = history[1:] while history and sum(estimate_tokens(m["content"]) for m in history) > max_tokens: history.pop(0) # 丢掉最早的一条 if system_msg: history.insert(0, system_msg) return history这里的核心思想是:越早的消息越可能被丢弃,最新的对话内容永远完整保留。为什么?因为聊天场景里,模型回答所依赖的通常是最近的几轮,而不是三天前的寒暄。这也是一种工程取舍,和前端做虚拟列表只渲染可视区域是同一个道理——资源有限,优化关键部分,而不是平均用力。
4. 第二块拼图:流式输出与终端交互体验
4.1 为什么必须做流式:没有打字机效果的 CLI 是残废的
如果模型生成 500 个 token 需要 10 秒,而你的程序在这 10 秒里什么都不显示,用户会怎么想?大概率会觉得程序卡死了,直接 Ctrl+C 走人。前端开发里我们太熟悉这种体验了:一个按钮点击后如果没有任何 loading 状态,用户会反复点击。终端里虽然没有按钮,但"等待焦虑"是一样的。
流式输出(Streaming)解决的就是这个问题。模型一边生成,程序一边把生成的内容打印到终端,形成类似打字机的效果。用户能实时看到回复在长出来,就知道程序活着,而且能看到它正在往哪个方向生成。
从技术上讲,流式的原理也很简单:模型接口支持 SSE(Server-Sent Events)协议,返回的数据不是一口气给完的,而是切分成很多小块,按顺序推送。客户端每收到一块就渲染一块。这个机制比 WebSocket 还简单——单向的,服务器只管推,客户端只管收。
4.2 用 openai SDK 还是手写 requests:我的选择
我在 2.3 节埋了个问题:为什么用 openai 库而不是直接 requests?现在来回答。
手写 requests 做 SSE 解析是可行的,核心代码如下:
import requests import json def stream_chat_with_requests(messages): resp = requests.post( f"{BASE_URL}/chat/completions", headers={ "Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json" }, json={ "model": MODEL_NAME, "messages": messages, "stream": True }, stream=True, timeout=(10, 300) ) for line in resp.iter_lines(): if not line: continue line = line.decode("utf-8") if line.startswith("data: "): data = line[6:] if data == "[DONE]": break chunk = json.loads(data) delta = chunk["choices"][0]["delta"].get("content") if delta: yield delta这段代码不长,但有几个细节要小心:iter_lines 需要 stream=True;空行要跳过;SSE 的 data: 前缀要剥掉;[DONE] 标记要单独处理;每一行可能是一个完整的 JSON。对于 v1 项目,手写完全可行,而且能让你把 SSE 机制彻底吃透。
但如果你只想快速把工具做出来,更省事的方案是直接用 openai 库:
from openai import OpenAI client = OpenAI(api_key=OPENAI_API_KEY, base_url=BASE_URL) def stream_chat(messages): stream = client.chat.completions.create( model=MODEL_NAME, messages=messages, stream=True, temperature=TEMPERATURE, max_tokens=MAX_TOKENS ) for chunk in stream: if chunk.choices[0].delta.content: yield chunk.choices[0].delta.content我最后选择了 openai 库,不是因为它比手写更高端,而是因为它帮我处理了网络重试、超时、连接复用这些底层问题,让我能把精力集中在业务逻辑上。但请注意:我建议你先手写一遍 requests 版本,再换成 SDK。这个过程的价值在于,你会真正理解"流式返回的本质是 SSE",将来即使换一个没有官方 SDK 的模型服务,你也能自己造轮子。
4.3 终端渲染的细节:flush、换行与 Ctrl+C
流式输出写起来容易,但终端渲染有几个细节,不处理就会显得很业余。
第一个坑是缓冲。如果你用 print(delta, end='') 而不带 flush=True,你会发现文本并不是逐字出现的,而是等缓冲区满了或者程序结束才一次性刷出来。原因在于 Python 的 print 默认走缓冲。解决方案很简单:
for delta in stream_chat(messages): print(delta, end="", flush=True)flush=True 强制每次 print 立即写入终端,打字机效果就出来了。
第二个坑是换行。模型输出的内容可能是很长的段落,也可能包含代码块。如果所有内容都原样打印,长段落会在终端里自动折行,这是终端自己的行为,不需要你处理。但如果你要自定义缩进(比如每行前面加个前缀),就要小心:stream 出来的内容是按 token 切分的,一个"你好\n世界"可能被切成"你好"和"\n世界"两段,你如果在每一段前面都手动加前缀,就会加乱掉。稳妥的做法是:把流式文本累积到行缓冲区,遇到 \n 再整行渲染,或者干脆不做复杂装饰,原样打印。
第三个坑是 Ctrl+C。前端常用的 AbortController 在 Python 命令行里没有对应的内置机制,当用户按 Ctrl+C 中断流式输出时,程序会抛 KeyboardInterrupt。v1 的做法是捕获这个异常,优雅地结束当前回合,把已经生成的内容保存进历史,然后回到输入状态:
try: for delta in stream_chat(messages): print(delta, end="", flush=True) except KeyboardInterrupt: print("\n[interrupted] 当前回答已停止。", flush=True)这比直接让程序退出要合理得多,符合"命令行工具"的使用预期。前端同学可以把它想成是 fetch 请求被用户取消后,你在 catch 里做清理工作的 Python 版本。
5. 前端思维转换实录:五个坑,每个都值得单独说
这一节是 Day 13 最值钱的部分。作为一名前端开发,我在写这个项目的过程中踩了至少五个坑,每一个都是"前端经验"和"Python 工程习惯"正面冲突的结果。写下来,给同样在转型路上的朋友一个预演。
5.1 异步认知冲突:Node 的 async/await 和 Python 的同步阻塞
前端这两年被 async/await 洗脑洗得非常彻底,看到网络请求就条件反射地想要 await。到了 Python 里,如果你用 requests 库,它是同步阻塞的:你发一个请求,程序就停在那里等响应,不会有任何事件循环让你切出去干别的事。
我一开始非常不习惯,总觉得"同步阻塞"是一种落后的设计。但写完这个项目我才意识到,对于命令行工具来说,同步反而是最合理的模型——程序本来就在等用户输入,等待网络响应和等待用户输入没什么本质区别,一个线程从头跑到尾,状态管理变得极其简单。
如果你想在 Python 里用异步,那是另一个世界:aiohttp、asyncio、await 关键字都有,但代码复杂度会上一个台阶。v1 完全没有必要。这个认知转变对我很重要:不是所有"更现代"的方案都适合当前场景,工具的核心是匹配需求,不是追逐时髦。
5.2 print 不刷新:你在终端里"卡住"的真相
这个坑我在 4.3 提过,但值得再展开一次。第一次跑通流式输出时,我以为成功了——代码看着没问题,逻辑也对,但屏幕上就是什么东西都不显示,一直等到整个回复结束才一口气全部打出来。我当时以为模型接口有问题,各种排查,最后才发现问题出在 print 的缓冲机制上。
Python 的 print 默认输出到 stdout,stdout 在非交互环境(比如管道、重定向到文件)下是全缓冲的,只有在终端环境下才是行缓冲。我一开始测试的时候用了管道把输出重定向到文件,自然触发全缓冲,导致"流式"变成了"攒一波打一波"。flush=True 是解决方案,但理解"为什么会有这个坑"比记住 flush 参数更重要。
同样的场景在前端也有对应:浏览器里 console.log 的表现、以及 React 的批量更新,本质上也是"缓冲"思想。只是 Python 把这种缓冲暴露得更直接,逼着你去理解底层。
5.3 OpenAI 兼容接口的返回结构:和前端接口约定完全不同
第一次看到 OpenAI 兼容接口的返回结构时,我是有点懵的。每个 chunk 长这样:
{ "choices": [ { "delta": { "content": "你" } } ] }content 嵌套在 choices[0].delta.content 里,中间隔了两层。这和前端常见的接口约定(data.content 扁平结构)相比,显得很"重"。但理解了就明白,这样设计是有原因的:choices 数组是为了支持一次返回多条候选结果(n 参数),delta 是为了兼容非流式和流式两种模式——非流式返回 choices[0].message.content,流式返回 choices[0].delta.content,结构保持一致,只是字段不同。
前端同学看到这种"为了兼容而多加一层"的设计应该很有共鸣——这不就是后端版的"适配层"嘛。你不需要记住整个对象树,只需要记住"流式取内容看 choices[0].delta,非流式看 choices[0].message",这个手感跟操作受控组件的数据路径有点类似。
5.4 Windows 终端编码:GBK 与 UTF-8 的战争
如果你在 Windows 上跑这个项目,很有可能会遇到我踩过的第四个坑:程序打印中文时,终端直接报 UnicodeEncodeError,或者显示成乱码。原因很简单:Windows 终端的默认编码通常是 GBK(code page 936),而模型的输出、你的代码都是 UTF-8 编码。
解决方案有三个,按优先级排列:
- 设置环境变量 PYTHONIOENCODING=utf-8,这会影响 Python 的 stdout/stderr 编码。
- 在代码开头调用 sys.stdout.reconfigure(encoding="utf-8"),一劳永逸。
- 升级到 Windows Terminal(而不是老的 conhost),它对 UTF-8 的支持更好。
我个人建议第 2 种,因为它写进代码里,跟着项目走,其他人 clone 下来也能直接跑:
import sys if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8")这个小细节,前端同学在做 Node 脚本的时候可能从没注意过,因为 Node 内部统一用 UTF-8,但 Python 的编码行为更依赖运行时环境。遇到问题别慌,先查是不是编码的锅。
5.5 字典取值:用 .get() 而不是怕 KeyError
最后一个坑,属于"代码习惯"层面的。前端从对象里取值,经常直接 user.name,如果是 undefined 出了错,我们习惯了"直接访问属性"这种宽松的方式(虽然也有可选链 user?.name)。到了 Python 里,如果你直接 dict["name"],键不存在会抛 KeyError,整个程序就中断了。
一开始我非常不适应,写了几行代码就崩一次:resp["choices"][0]["delta"]["content"],只要返回结构稍有变化(比如 API 报错,返回的是 error 字段而不是 choices),程序直接炸。
后来养成习惯,凡是取外部数据(API 返回、用户输入、配置文件)都用 .get():
choices = data.get("choices", []) if not choices: error_msg = data.get("error", {}).get("message", "未知错误") raise RuntimeError(f"API error: {error_msg}") delta = choices[0].get("delta", {}) content = delta.get("content")这看起来只是一个小习惯,但背后是两种编程哲学的差异:JavaScript 的哲学是"宽松,能跑就行",Python 的哲学是"显式,错误要尽早暴露"。做 AI 项目时,外部接口返回结构往往会变,防御式编程的收益极大。这个习惯,是我 Day 13 最大的收获之一。
6. 实测效果与 v2 规划
6.1 真实对话测试:三种场景下的表现
跑通之后我做了三类测试,分别验证工具的基本能力、上下文记忆能力和抗干扰能力。
第一类测试是基础问答。输入"用三句话解释什么是递归",它能正常流式输出,终端里逐字打出答案,响应速度取决于模型服务本身的出字速度,体感上和网页版的中等速度差不多。
第二类测试是多轮对话。我先说"我叫小明,我喜欢吃火锅",然后隔几轮再问"我叫什么名字、我喜欢吃什么",它能从历史中正确回答出来。这说明 JSON 历史方案和上下文组装逻辑是通的。
第三类测试是错误场景。我把 API Key 改错、网络断掉,程序能打印出明确的错误信息,而不是丑陋的 traceback。这个要归功于第 10-11 天学的异常处理,我在 api_client.py 里把所有可能的异常都兜住了,并用 rich 库打印红色的错误提示。
6.2 性能感受与 v1 的局限性
实测下来我最直观的感受是:流式输出的体验远远好于等半天一次性输出,哪怕底层模型出字速度一样,用户体感上更快了,因为它把等待时间转化成了阅读时间。
但也暴露了几个 v1 的局限性:
- 对话历史的截断策略比较粗糙,如果某条消息特别长(比如粘贴了一整篇文章),即便只有两轮对话,也可能触发截断,把早期消息扔掉。
- 没有并发控制,用户输入过程中如果按下多个回车,输入循环会混乱。v1 我只做了最简单的逐行读取,没有屏蔽连击。
- 模型的知识截止时间和上下文窗口完全取决于配置,如果配置一个很小的上下文窗口模型,却把 MAX_CONTEXT_TOKENS 设很大,接口会直接报错。
这些限制不影响 v1 的可用性,但让我对"从 demo 到产品"的距离有了非常清醒的认知。
6.3 v2 规划:从"能用"到"好用"
Day 13 做完之后,我列了一个 v2 清单,不是空想,每个功能都是从 v1 使用痛点里冒出来的:
- 命令系统:支持 /clear、/save、/load 这类以斜杠开头的内置指令,让工具具备基础的管理能力。
- 多会话管理:每次对话保存为独立文件,用 /list 查看历史会话列表,用 /switch 切换。这个功能是前端"路由"思维的产物,放在 CLI 里就是一个简单的文件索引。
- 函数调用(Function Calling):让模型可以请求调用预定函数,比如查询时间、读取文件内容。这是通往 Agent 的关键一步。
- 集成 rich 的 Markdown 渲染:让代码块、加粗、列表在终端里以更漂亮的形式展示出来。
- 支持本地模型(Ollama 等):通过统一的 OpenAI 兼容接口,把 BASE_URL 指到 localhost,就能从"云端模型"切换到"本地模型"。
这些功能我大概率会在 Day 20 之前陆续做掉。但 v1 的价值不在功能的丰富程度,而在于:一个前端开发者在第 13 天,第一次完完整整地拥有了一条从零到一的 AI 工具开发闭环。这条链路上的每一个环节我都亲手推过,接下来的学习就不再是"理解概念",而是"在这个框架上做增量"。对我个人来说,Day 13 最大的收获不是代码本身,而是终于明白了一个朴素的道理:学 AI 和学前端一样,真正让你成长的,永远是那个你逼着自己做完的完整项目。