ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零整合命令行AI助手:上下文管理与流式输出实战

从零整合命令行AI助手:上下文管理与流式输出实战 1. 项目缘起与整体设计思路1.1 为什么第 13 天要做一个命令行 AI 助手前 12 天我一直在拆零碎的东西调 API、写 prompt、处理流式输出、做上下文管理、搞简单的 RAG。单看每一块都能跑但真到用的时候你会发现这些碎片散落在不同的脚本里改一个参数要翻三个文件。第 13 天我决定不再往后堆新知识点而是把前 12 天的东西全部收拢到一个能真正用起来的命令行工具里。选命令行而不是 Web 界面理由很实在。第一前端转 AI 的人最容易掉进的坑就是“界面先行”——花两天调 CSS核心逻辑一行没写。命令行逼着你把注意力放在数据流上输入怎么进来、上下文怎么拼、模型怎么调、结果怎么出去。第二命令行工具天然适合做管道cat log.txt | ai 帮我找报错这种用法是 Web 界面给不了的。第三调试成本低不用起服务、不用开浏览器终端里直接看输出。这个 v1 版本的目标很明确一个能记住对话、能读本地文件、能切换模型、能流式输出的命令行助手。不追求功能多追求每一块都是前 12 天学过的东西的真实整合。适合已经写过几个零散 AI 脚本、但还没把它们串成工具的人参考。1.2 整体架构四层拆解我把整个工具拆成四层这个分层不是拍脑袋定的是踩过坑之后总结的。第一层是输入层负责接收用户输入。这里要处理两种模式交互式对话REPL和单次命令one-shot。交互式用input()循环单次命令从sys.argv拿。看起来简单但这里有个坑——如果你用input()做多行输入用户粘贴一段代码进去会直接断行。我后来改成了检测到特定结束符才提交。第二层是上下文层这是整个工具的核心。它管三件事对话历史、系统提示词、外部文件内容。前 12 天我最大的教训就是上下文管理不能散着写必须有一个统一的Context对象所有要发给模型的东西都从这里取。这样你换模型、改窗口大小、加 RAG都只动这一层。第三层是模型层封装 API 调用。这里的关键是接口统一——不管你后面接的是哪家模型对外都暴露同一个chat(messages, streamTrue)方法。我见过太多人把 API 调用写死在业务逻辑里换个模型要改十几个地方。第四层是输出层处理流式渲染、Markdown 高亮、错误提示。流式输出这块前 12 天专门练过这次直接复用。提示分层不是为了显得专业是为了让你改一个功能时不用动其他三层。如果你发现改个模型要动输入层说明分层没做对。1.3 技术选型为什么是这些库选型这块我纠结过最后定下来的组合是openaiSDK 做 API 调用、rich做终端渲染、prompt_toolkit做输入、python-dotenv管配置。openaiSDK 而不是requests裸调是因为它已经帮你处理了流式解析、重试、超时这些脏活。前端转过来的人容易有“什么都自己写”的执念但 SDK 该用就用省下来的时间拿去调 prompt 更值。rich是终端渲染的最优解没有之一。它能做 Markdown 高亮、代码块语法着色、进度条、表格。你如果自己用 ANSI 转义码写光处理换行和宽度就能耗一天。prompt_toolkit而不是内置input()是因为它支持历史记录上下键翻、多行编辑、自动补全。做命令行工具输入体验直接决定你愿不愿意天天用它。配置用.env文件python-dotenv加载。这里有个细节API key 绝对不能硬编码也不能提交到 git。.env加.gitignore是标配。层级选型替代方案选择理由API 调用openai SDKrequests 裸调自带流式解析与重试终端渲染richANSI 手写Markdown 与代码高亮开箱即用输入交互prompt_toolkitinput()历史记录与多行编辑配置管理python-dotenv环境变量硬写隔离敏感信息便于切换2. 核心模块的细节拆解与实操要点2.1 上下文管理别让历史无限膨胀上下文管理是新手最容易翻车的地方。我第一版就是简单地把所有历史消息 append 到一个 list 里跑了几十轮之后直接超 token 限制API 报错。后来我加了三层控制。第一层是滑动窗口。保留最近 N 轮对话N 默认设 10。这个数字不是随便定的——一轮对话大概 200 到 500 token10 轮就是 2000 到 5000 token加上系统提示词和文件内容留足余量。第二层是系统提示词固定。系统提示词永远放在 messages 的第一个位置不参与滑动。这样模型的“人设”不会因为窗口滑动而丢失。第三层是文件内容按需注入。不是每次对话都把文件塞进去而是用户显式用/file命令加载时才注入并且注入后标记为“可裁剪”——当 token 紧张时优先裁掉文件内容保留对话历史。class Context: def __init__(self, system_prompt, window_size10): self.system_prompt system_prompt self.window_size window_size self.history [] # 只存 user/assistant 轮次 self.files [] # 注入的文件内容 def build_messages(self): messages [{role: system, content: self.system_prompt}] # 文件内容作为一条 system 消息注入 if self.files: file_content \n\n.join(self.files) messages.append({ role: system, content: f以下是用户提供的参考资料\n{file_content} }) # 滑动窗口取最近 N 轮 recent self.history[-self.window_size * 2:] messages.extend(recent) return messages这里有个细节值得说history里存的是成对的 user/assistant 消息所以取最近 N 轮要乘 2。我一开始忘了乘 2结果窗口实际只有 5 轮模型老是“失忆”。注意滑动窗口裁掉的消息不是删了就完事如果你要做长期记忆得把裁掉的内容摘要后存起来。v1 版本我先不做但接口留好了。2.2 流式输出的终端渲染流式输出前 12 天练过但这次整合时发现一个新问题流式 chunk 和 Markdown 渲染冲突。模型一个字一个字吐出来你没法等它吐完再渲染 Markdown但边吐边渲染又会导致代码块标记被拆散渲染出错。我的解法是分两阶段流式阶段只做纯文本输出用一个缓冲区累积检测到流结束时再用 rich 重新渲染整个 Markdown。这样用户能看到实时输出最终又能看到格式化的结果。from rich.console import Console from rich.markdown import Markdown console Console() def stream_chat(client, messages): buffer with console.status(思考中...): stream client.chat.completions.create( modelyour-model, messagesmessages, streamTrue ) for chunk in stream: delta chunk.choices[0].delta.content if delta: buffer delta console.print(delta, end, highlightFalse) console.print() # 换行 return bufferhighlightFalse这个参数很关键。rich 默认会对输出做语法高亮流式输出时每个 chunk 单独高亮会导致颜色乱跳。关掉之后输出稳定很多。2.3 命令系统用斜杠命令扩展功能一个纯对话的工具用久了会腻你得给它加“手脚”。我设计了一套斜杠命令/file加载文件、/clear清空历史、/model切换模型、/save保存对话、/exit退出。命令解析放在输入层用简单的字符串前缀匹配。这里不要上 argparse 或者 click因为交互式场景下这些库反而累赘。一个if line.startswith(/)就够了。def handle_command(line, ctx): parts line.strip().split(maxsplit1) cmd parts[0] arg parts[1] if len(parts) 1 else if cmd /file: with open(arg, r, encodingutf-8) as f: ctx.files.append(f.read()) return f已加载文件{arg} elif cmd /clear: ctx.history.clear() ctx.files.clear() return 上下文已清空 elif cmd /exit: raise SystemExit else: return f未知命令{cmd}命令处理函数返回一个字符串作为提示信息主循环负责打印。这样命令逻辑和渲染逻辑解耦后面加新命令不用动主循环。2.4 配置与密钥管理配置这块我踩过一个坑一开始把模型名、API 地址、key 全写在代码里换环境要改代码。后来全部抽到.envAI_API_KEYyour_key_here AI_BASE_URLhttps://your-endpoint AI_MODELyour-model-name AI_MAX_TOKENS2048 AI_TEMPERATURE0.7代码里用os.getenv读读不到就给默认值。AI_BASE_URL这个字段很重要——它让你的工具不绑定特定服务商只要接口兼容就能用。提示.env一定要加进.gitignore。我见过有人把 key 提交到公开仓库几分钟内就被扫走刷爆额度。这不是危言耸听是真实发生的事。3. 完整实操流程与关键环节实现3.1 项目结构搭建先把目录结构定下来别小看这一步结构乱了后面越写越烦。ai-cli/ ├── .env ├── .gitignore ├── requirements.txt ├── main.py ├── core/ │ ├── __init__.py │ ├── context.py │ ├── client.py │ └── commands.py └── utils/ ├── __init__.py └── render.pycore放核心逻辑utils放工具函数main.py只做入口和主循环。这个结构的好处是每个文件职责单一测试的时候可以单独 import。requirements.txt内容openai1.0.0 rich13.0.0 prompt_toolkit3.0.0 python-dotenv1.0.03.2 模型客户端封装core/client.py封装 API 调用对外只暴露一个chat方法。import os from openai import OpenAI from dotenv import load_dotenv load_dotenv() class AIClient: def __init__(self): self.client OpenAI( api_keyos.getenv(AI_API_KEY), base_urlos.getenv(AI_BASE_URL) ) self.model os.getenv(AI_MODEL, default-model) self.max_tokens int(os.getenv(AI_MAX_TOKENS, 2048)) self.temperature float(os.getenv(AI_TEMPERATURE, 0.7)) def chat(self, messages, streamTrue): return self.client.chat.completions.create( modelself.model, messagesmessages, max_tokensself.max_tokens, temperatureself.temperature, streamstream )temperature默认 0.7 是个平衡点。做代码助手可以调到 0.2 更稳定做创意写作可以调到 1.0。这个值我建议做成可配置不同任务用不同值。3.3 主循环实现main.py的主循环是整个工具的骨架。from prompt_toolkit import PromptSession from prompt_toolkit.history import InMemoryHistory from core.context import Context from core.client import AIClient from core.commands import handle_command from utils.render import stream_render SYSTEM_PROMPT 你是一个命令行 AI 助手回答简洁准确。 涉及代码时给出可直接运行的示例涉及步骤时分点说明。 def main(): ctx Context(SYSTEM_PROMPT) client AIClient() session PromptSession(historyInMemoryHistory()) print(AI 助手已启动输入 /exit 退出/help 查看命令) while True: try: user_input session.prompt( ) except (KeyboardInterrupt, EOFError): break if not user_input.strip(): continue if user_input.startswith(/): result handle_command(user_input, ctx) if result: print(result) continue ctx.history.append({role: user, content: user_input}) messages ctx.build_messages() reply stream_render(client, messages) ctx.history.append({role: assistant, content: reply}) if __name__ __main__: main()这里有个细节KeyboardInterrupt和EOFError都要捕获。CtrlC 中断当前输入CtrlD 退出程序。两个都处理了体验才顺。3.4 流式渲染函数utils/render.py里的stream_render负责流式输出并返回完整文本。from rich.console import Console from rich.markdown import Markdown console Console() def stream_render(client, messages): buffer stream client.chat(messages, streamTrue) for chunk in stream: if not chunk.choices: continue delta chunk.choices[0].delta.content if delta: buffer delta console.print(delta, end, highlightFalse) console.print() return bufferif not chunk.choices这个判断是防御性的。有些服务商在流结束时发一个空的 choices 数组不判断会报 IndexError。这种边界情况文档里不写但实际会遇到。3.5 参数计算token 预算怎么估很多人不知道自己的上下文能装多少我教你一个粗估方法。英文大概 1 token 等于 4 个字符中文大概 1 token 等于 1.5 到 2 个字符。假设你的模型上下文是 8K token系统提示词占 200文件内容占 2000那留给对话的就剩 5800 token。按每轮对话 400 token 算能装 14 轮。我默认窗口设 10 轮留了余量。这个计算不用精确但心里要有数不然跑到一半报错很尴尬。内容类型估算 token说明系统提示词约 200固定占用单个文件约 2000按 4000 中文字符估单轮对话约 400用户加回复10 轮对话约 4000滑动窗口上限4. 常见问题与排查技巧实录4.1 流式输出卡顿或断流最常见的问题是流式输出到一半卡住。排查顺序是这样的先看是不是网络问题加个超时重试再看是不是服务商限流降低请求频率最后看是不是自己的缓冲区处理有问题。我遇到过一次是console.print在循环里调用太频繁导致的性能问题。解法是攒够一定字符再打印比如每 10 个字符 flush 一次。但这样实时性会差一点权衡下来我还是选了逐字符打印因为体验更顺。4.2 上下文超限报错报错信息通常是maximum context length exceeded。这时候别慌先打印build_messages()的结果数一下总字符数。如果确实是历史太长调小window_size。如果是文件太大用/clear清掉重新加载。我建议在Context里加一个estimate_tokens方法每次构建 messages 时打印一下估算值。这样你能提前发现膨胀趋势而不是等报错。4.3 中文乱码Windows 终端默认编码是 GBK读中文文件会乱码。解法是在open()时显式指定encodingutf-8并且在程序开头设置sys.stdout.reconfigure(encodingutf-8)。这个坑在 Linux 和 macOS 上不会遇到但 Windows 用户必踩。4.4 常见问题速查表现象可能原因排查方法解决方式流式输出卡住网络或限流加日志看卡在哪加重试与超时上下文超限历史或文件过大打印 messages 长度调小窗口或清文件中文乱码编码不匹配检查终端编码显式指定 utf-8命令无响应前缀匹配错误打印解析结果检查 startswith 逻辑API 报 401key 无效检查 .env 加载确认 key 与地址匹配4.5 几个我踩过的坑第一个坑是把 API key 写进代码。这个前面说过但值得再强调一次。我现在的习惯是新建项目第一件事就是写.gitignore把.env加进去然后再写代码。第二个坑是忘了处理空输入。用户直接回车你的代码把空字符串发给模型模型回一堆莫名其妙的东西。加个if not user_input.strip(): continue就解决了。第三个坑是命令和对话混淆。用户输入/file但后面没跟路径你的代码直接open()报错。命令处理函数里要对参数做校验缺参数就提示用法。提示做命令行工具错误提示要具体。“出错了”这种提示等于没提示要告诉用户错在哪、怎么改。5. 后续扩展方向与个人体会5.1 这个 v1 还能往哪长v1 跑通之后能扩展的方向很多。最直接的是加本地文件检索把前 12 天学的 RAG 接进来让助手能基于你的项目文档回答问题。再往上是加工具调用让助手能执行 shell 命令、读写文件从“聊天”变成“代理”。还有一个方向是多会话管理。现在退出就丢历史可以加个/save和/load把对话存成 JSON 文件。这个实现简单但实用性很高。配置方面可以加模型预设比如/model fast切到快模型/model smart切到强模型。不同任务用不同模型成本和效果都能优化。5.2 前端转 AI 的一点真实感受写到第 13 天我最大的感受是AI 应用开发和前端开发的核心差异在数据流不在界面。前端你花大量时间在状态管理和渲染优化上AI 应用你花大量时间在上下文构造和输出解析上。前 12 天学的那些零碎东西单独看都很简单但整合起来会发现它们之间有耦合。比如流式输出和 Markdown 渲染会冲突上下文管理和 token 预算会互相制约。这些耦合点才是真正学到东西的地方。命令行工具的好处是它逼你直面这些耦合没有界面可以躲。你如果也在转 AI我建议别一上来就做 Web 应用先做个命令行工具把数据流跑通。等数据流顺了套什么界面都容易。最后分享一个小技巧给工具加个/debug命令打印当前上下文的所有消息和 token 估算。调试的时候比什么都好用比你在代码里到处加 print 强多了。这个命令我几乎每个 AI 工具都会加强烈推荐。
返回列表