
1. 从命令行助手到带记忆的 Agent这个项目到底在做什么前端转 AI 的第 14 天我决定不再写那些调个 API 打印一句话的玩具 demo 了。前面十几天陆陆续续把 Python 基础、OpenAI SDK 调用、Prompt 工程、函数调用Function Calling这些零碎的东西过了一遍但始终没有一个能拿得出手、真正像产品的东西。于是这一天我给自己定了个目标把之前那个只会一问一答的命令行 AI 助手升级到 v2核心就加一样东西——记忆系统。为什么是记忆系统因为我在实际用 v1 的时候被恶心到了。每次问它我刚才说的那个函数怎么改它一脸茫然因为每一轮对话都是独立的上下文一关就全没了。这就像你跟一个失忆的人聊天每句话都得从头解释背景体验极差。而市面上那些真正好用的 AI 编程助手、Agent 产品之所以让人觉得聪明很大一部分功劳就在于它们能记住你是谁、你之前聊过什么、你的项目背景是什么。所以这个 v2 的目标很明确做一个跑在命令行里的 AI 助手它具备短期记忆当前会话的上下文和长期记忆跨会话持久化的关键信息并且能通过Agent的方式调用工具比如读写文件、执行命令同时把Token消耗控制在合理范围内。听起来有点唬人但拆开来看每一块都不复杂难的是把它们串起来并且不踩坑。这篇文章我会把整个项目的设计思路、核心代码、参数计算、踩过的坑全部摊开讲。适合谁看如果你也是从前后端转 AI 的或者你已经会调 API 但不知道怎么做出一个有记忆、能干活的助手那这篇就是写给你的。哪怕你完全没接触过 Agent我也会用生活化的类比把概念讲清楚保证你能照着复现。先说结论整个 v2 大概 400 行 Python核心依赖就openai和tiktoken两个库记忆系统用最朴素的 JSON 文件做持久化没有上向量数据库原因后面细说。跑起来之后你可以跟它连续聊几十轮关掉终端再打开它还记得你昨天让它改的那个 bug。2. 整体架构设计为什么这么拆2.1 三层记忆模型的设计考量一提到记忆系统很多人第一反应就是上向量数据库、做 RAG 检索。我一开始也这么想但冷静下来分析了一下需求发现对这个命令行助手来说向量检索是过度设计。我把它拆成了三层这个分层思路参考了人类记忆的常见模型工作记忆Working Memory就是当前这一轮对话的完整消息列表直接塞进 API 的messages参数里。它决定了助手当下能看到的上下文。短期记忆Short-term Memory最近 N 轮对话的摘要。当对话轮数超过阈值就把早期的对话压缩成一段摘要避免 Token 爆炸。长期记忆Long-term Memory跨会话持久化的事实。比如用户在做前端转 AI 的项目用户偏好用 Python用户的时区是东八区。这些以键值对形式存在本地 JSON 文件里每次启动时加载进 System Prompt。为什么这么分因为这三层的访问频率和容量完全不同。工作记忆每轮都要用容量最小受 Token 限制短期记忆是压缩后的中等容量长期记忆是精选的事实容量可以很大但只在启动时读一次。用一套向量检索去处理这三种截然不同的需求纯属给自己找麻烦。提示不要一上来就追求技术先进性。向量数据库适合海量非结构化知识的语义检索但如果你要记的只是几十条结构化事实一个 JSON 文件加字符串匹配就够了还省了 embedding 的 Token 成本和延迟。2.2 为什么用 Agent 模式而不是纯对话v1 是纯对话你问它答它不能碰你的文件系统。v2 我改成了 Agent 模式也就是给它配了几个工具Tool它能自己决定什么时候调用。目前配了三个工具名功能触发场景read_file读取指定路径文件内容用户问帮我看看 xxx.py 哪里有问题write_file写入内容到指定文件用户说把这段代码存到 test.pyremember把一条事实写入长期记忆用户说记住我以后都用 PythonAgent 的核心在于模型自己决定调用哪个工具而不是我写死 if-else。这背后靠的是 Function Calling 机制我把工具的 schema 描述传给模型模型返回一个结构化的调用请求我执行完再把结果喂回去。这个循环就是所谓的Agent Loop。用生活类比纯对话助手像一个只能动嘴的顾问Agent 则像一个能动手的助理。你说帮我把桌上的文件整理一下顾问只能告诉你你应该按日期分类而助理会真的去动手整理。当然动手就意味着风险所以工具设计必须谨慎这个后面会重点讲。2.3 Token 预算的全局规划整个项目最容易被忽视、但最影响体验的就是 Token 管理。我给自己定了个预算单次请求的输入 Token 不超过 4000输出不超过 1000。为什么是 4000因为主流模型的上下文窗口虽然动辄 128K但输入越长成本和延迟越高而且模型对中间部分的注意力会衰减俗称lost in the middle。4000 Token 大概是什么概念中文大约 2000-2500 字英文大约 3000 词。对于命令行助手来说System Prompt 占 500长期记忆占 300短期摘要占 500剩下的 2700 留给最近几轮原始对话。这个分配是我反复调出来的后面会讲怎么用tiktoken精确计算。3. 核心模块拆解与实操要点3.1 记忆系统的数据结构设计长期记忆我用了一个很朴素的 JSON 结构存在~/.ai_assistant/memory.json{ facts: [ {key: user_identity, value: 前端转 AI 的开发者, ts: 1700000000}, {key: preferred_language, value: Python, ts: 1700000100} ], summaries: [ {session_id: 20240101_1200, summary: 讨论了如何用 tiktoken 计算 Token, ts: 1700000200} ] }这里有个关键设计facts 用 key-value 而不是纯文本列表。为什么因为纯文本列表会无限增长而且容易重复。用 key 之后同一个 key 再写入就是更新而不是追加天然去重。比如用户先说我用 Python后来说算了还是用 TypeScript那preferred_language这个 key 的值会被覆盖不会两条都留着让模型困惑。ts字段是时间戳用途是记忆衰减。太老的、不常被引用的记忆可以在加载时过滤掉。我目前的策略是超过 30 天且从未被remember工具更新过的记忆加载时降权放到 System Prompt 靠后的位置。注意JSON 文件读写一定要加文件锁或者用写临时文件再原子替换的方式否则程序崩溃时可能把记忆文件写坏导致下次启动直接报错。我踩过这个坑丢过一次记忆血的教训。3.2 短期记忆的摘要压缩策略当对话轮数超过 10 轮我就触发摘要。具体做法是把最早的 5 轮对话拿出来让模型生成一段 200 字以内的摘要然后把这 5 轮从消息列表里删掉换成一条system角色的摘要消息。这里有个细节很多人会做错摘要要保留决策和事实丢弃寒暄和试错过程。所以我的摘要 Prompt 是这样写的请把以下对话压缩成不超过 200 字的摘要只保留 1. 用户明确表达的需求和偏好 2. 已经确定的技术方案和结论 3. 未解决的问题 不要保留寒暄、重复确认、以及被否决的方案。为什么要强调被否决的方案不要保留因为如果摘要里写了用户考虑过用向量数据库模型下一轮可能又把这个被否决的方案翻出来造成困扰。这是我在实际调试中发现的摘要质量直接决定了长对话的连贯性。3.3 Function Calling 的工具 schema 设计工具 schema 写得好不好直接决定模型会不会正确调用。我以remember工具为例remember_tool { type: function, function: { name: remember, description: 当用户表达了需要长期记住的个人偏好、身份信息或项目背景时调用。不要用于临时性的对话内容。, parameters: { type: object, properties: { key: { type: string, description: 记忆的键名用英文小写下划线如 preferred_language }, value: { type: string, description: 记忆的值简洁明确 } }, required: [key, value] } } }关键在description里那句不要用于临时性的对话内容。如果不写这句模型会变得过度积极你说我今天有点累它都想记下来。工具的 description 本质上是在给模型做行为约束写得越具体误触发越少。3.4 命令行交互层的实现细节命令行界面我用的是 Python 内置的input()加一个 while 循环没有上rich或prompt_toolkit。原因很简单依赖越少越不容易在别人机器上跑不起来。但纯input()有个问题——不支持多行输入。用户想粘贴一段代码进去回车就被截断了。我的解决方案是用一个哨兵字符串输入开始多行模式再输入结束。虽然土但管用。另外我加了几个内置命令/memory打印当前长期记忆/forget key删除某条记忆/clear清空当前会话的工作记忆/tokens显示当前请求的 Token 估算这几个命令极大提升了调试效率尤其是/tokens能让你直观看到每轮对话消耗了多少。4. 完整实操流程与关键代码4.1 环境准备与依赖安装先把环境搭起来。我用的是 Python 3.10理论上 3.8 以上都行。python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install openai tiktoken就这两个库。openai负责调 APItiktoken负责精确计算 Token。为什么不用transformers自带的 tokenizer因为tiktoken更轻量而且和主流模型的 tokenizer 对齐得更好。API Key 我建议放在环境变量里不要硬编码export OPENAI_API_KEY你的key提示如果你用的是兼容 OpenAI 协议的第三方服务记得在初始化 client 时指定base_url。这个参数很多人会忘然后一直报 401 却找不到原因。4.2 Token 计算的精确实现Token 计算是整个项目的仪表盘没有它你就是盲开。核心代码import tiktoken def count_tokens(messages, modelgpt-4o-mini): try: encoding tiktoken.encoding_for_model(model) except KeyError: encoding tiktoken.get_encoding(cl100k_base) total 0 for msg in messages: # 每条消息有固定的开销 total 4 for key, value in msg.items(): if isinstance(value, str): total len(encoding.encode(value)) total 2 # 回复的起始开销 return total这里有个很多人不知道的细节每条消息除了内容本身还有约 4 个 Token 的固定开销角色标记、分隔符等。如果你只算内容长度会低估 10%-20%。这个 4 是我从官方文档和实测中确认的不同模型略有差异但作为估算足够。基于这个函数我实现了动态裁剪逻辑如果count_tokens(messages) 4000就从最早的非 system 消息开始删直到降下来。删之前先判断能不能触发摘要压缩能压就压不能压才硬删。4.3 Agent Loop 的完整实现这是整个项目的心脏。Agent Loop 的本质是一个模型思考 → 调用工具 → 观察结果 → 再思考的循环def agent_loop(client, messages, tools, max_iterations5): for i in range(max_iterations): response client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolstools, temperature0.7 ) msg response.choices[0].message # 没有工具调用直接返回文本 if not msg.tool_calls: messages.append({role: assistant, content: msg.content}) return msg.content # 有工具调用执行后继续循环 messages.append(msg) for tool_call in msg.tool_calls: result execute_tool(tool_call) messages.append({ role: tool, tool_call_id: tool_call.id, content: result }) return 达到最大迭代次数任务未完成max_iterations5是个安全阀。为什么需要它因为模型有时候会陷入调用工具 → 结果不满意 → 再调用的死循环烧 Token 还出不来。设个上限超过就强制返回避免无限循环。execute_tool函数里我用一个字典做分发def execute_tool(tool_call): name tool_call.function.name args json.loads(tool_call.function.arguments) if name read_file: return read_file(args[path]) elif name write_file: return write_file(args[path], args[content]) elif name remember: return remember(args[key], args[value]) return f未知工具: {name}4.4 记忆加载与 System Prompt 组装每次启动时先加载长期记忆拼进 System Promptdef build_system_prompt(memory): facts_text \n.join( f- {f[key]}: {f[value]} for f in memory[facts] ) return f你是一个命令行 AI 助手具备记忆能力。 以下是关于用户的已知信息 {facts_text if facts_text else 暂无} 规则 1. 回答简洁适合命令行阅读 2. 需要读写文件时调用对应工具 3. 用户表达长期偏好时调用 remember 工具 4. 不确定的信息不要编造 这里facts_text为空时要给个占位符暂无否则 System Prompt 里会出现一个空的列表模型可能会困惑。这种小细节看着不起眼但实测下来对稳定性有影响。4.5 一次完整的交互实录我把一次真实交互记录下来让你看看整个流程怎么跑你 帮我记住我以后都用 Python 写代码 [Agent] 调用 remember(keypreferred_language, valuePython) [工具返回] 已记住: preferred_language Python 助手 好的已记住你偏好使用 Python。 你 帮我看看 test.py 里有什么 [Agent] 调用 read_file(pathtest.py) [工具返回] def hello(): print(hi) 助手 test.py 里定义了一个 hello 函数功能是打印 hi。 你 /tokens 当前请求估算 Token: 312注意第三轮我用了/tokens命令312 这个数字说明整个上下文还很轻量。如果你发现这个数字经常超过 3500就该检查是不是记忆或摘要没生效。5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是新手最常遇到的问题。你明明配了工具模型却只顾着聊天。排查顺序如下现象可能原因解决完全不调用工具 description 太模糊把触发条件写具体偶尔调用temperature 太高降到 0.3-0.5调用错工具工具之间描述重叠明确区分各自适用场景参数格式错schema 没写 required补全 required 字段我遇到过一次read_file和write_file的描述都写了操作文件结果模型经常搞混。后来我把描述改成读取文件内容用于分析和将内容写入文件用于保存误调用率立刻降下来了。工具描述要描述意图而不是动作这是关键。5.2 记忆写入重复或冲突早期版本我发现同一个偏好被记了好几次因为模型每次都想调remember。解决办法是在remember函数里做去重def remember(key, value): memory load_memory() for fact in memory[facts]: if fact[key] key: fact[value] value fact[ts] int(time.time()) save_memory(memory) return f已更新: {key} {value} memory[facts].append({key: key, value: value, ts: int(time.time())}) save_memory(memory) return f已记住: {key} {value}用 key 做唯一标识存在就更新不存在才追加。这样无论模型调用多少次记忆里同一个 key 永远只有一条。5.3 Token 超限导致的报错报错信息通常是context_length_exceeded。这时候别慌先打印当前 messages 的 Token 数看看是哪部分膨胀了。我的经验是90% 的超限都是因为工具返回的结果太长。比如read_file读了一个几千行的文件直接塞进上下文瞬间爆掉。解决方案是给工具返回做截断def read_file(path, max_chars2000): with open(path, r, encodingutf-8) as f: content f.read() if len(content) max_chars: return content[:max_chars] f\n...已截断原文件共 {len(content)} 字符 return content截断时一定要告诉模型被截断了以及原始长度否则模型会以为文件就这么点内容给出错误结论。5.4 记忆文件损坏的恢复前面提过 JSON 写入的原子性问题。我的最终方案是写临时文件再替换def save_memory(memory): path MEMORY_PATH tmp path .tmp with open(tmp, w, encodingutf-8) as f: json.dump(memory, f, ensure_asciiFalse, indent2) os.replace(tmp, path) # 原子操作os.replace在大多数系统上是原子操作能保证要么写入成功要么保持原文件不变不会出现写一半崩溃导致文件损坏的情况。另外加载时加个 try-except文件坏了就重建一个空的别让程序直接崩。5.5 常见问题速查表问题排查方向快速修复助手失忆记忆文件路径不对打印实际加载路径响应很慢上下文太长用 /tokens 检查并裁剪工具报错参数 JSON 解析失败加 try-except 并返回错误信息给模型摘要质量差摘要 Prompt 不够具体明确要求保留决策和事实中文乱码文件编码问题统一用 utf-8 读写6. 我踩过的坑和几条实在的经验做这个 v2 花了我一整天其中一半时间在调试记忆系统。有几个经验我觉得比代码本身更值钱分享出来。第一别急着上向量数据库。我一开始差点就去装 chromadb 了后来算了一下我的长期记忆撑死几十条用向量检索的收益几乎为零反而引入了 embedding 的 Token 成本和检索延迟。结构化的事实就用结构化的存储这是最朴素的工程直觉。第二工具的能力边界要收窄。我最初的write_file允许写任意路径后来想想太危险了万一模型抽风写了系统文件怎么办。现在我只允许写到当前工作目录下的文件路径里带..的直接拒绝。Agent 越强大越要给它套笼头。第三摘要的触发时机比摘要算法更重要。我试过每轮都摘要结果 Token 是省了但连贯性极差也试过攒到 20 轮才摘要结果经常超限。最后定在 10 轮触发、每次压 5 轮这个节奏实测最舒服。第四给模型留我不知道的出口。System Prompt 里那句不确定的信息不要编造看着是废话但加上之后模型胡编乱造的情况明显减少。Agent 场景下模型编造一个不存在的文件路径比它说我不知道危害大得多。这个项目后续我打算再加两个东西一个是把长期记忆做成可检索的等记忆条数真的多起来再说另一个是加一个run_command工具让它能执行 shell 命令——但这个风险太大得先想清楚沙箱怎么做。如果你也在做类似的东西建议先把记忆和工具这两块打磨扎实别贪多。