手搓Claude Code-第十章 system_prompt:从零构建可复用的系统提示词配置骨架
发布时间:2026/9/26 10:11:33来源:尧图网络
1. 为什么你的 Agent 越跑越“健忘”从一段膨胀的 system_prompt 说起如果你写过最基础的 LLM Agentsystem_prompt 大概率就是一行字符串告诉模型它是谁、当前工作目录在哪、用 bash 解决问题。这个阶段很舒服改一行重启一次行为完全可预期。但当你开始给 Agent 加能力——读文件、写文件、子 Agent、技能库、记忆、上下文压缩——这个字符串会像滚雪球一样膨胀。我见过最夸张的一个版本启动时拼了 3000 多 token 的 system_prompt里面塞了技能目录、记忆索引、待办列表、用户偏好甚至还有一段“当用户说 remember 时要提取记忆”的元指令。问题不在于长而在于它只在程序启动时拼一次。用户第一句话是“帮我格式化代码”你注入了“用户偏好 Tab 缩进”的记忆这很合理。但用户第二句话变成“顺便读一下 README”跟缩进毫无关系那段记忆还赖在 prompt 里占 token。用户中途加载了一个新 skillprompt 里没有这个 skill 的说明。用户做完了 todo 的前两项prompt 里还显示“5 项待办全 pending”。这就是本章要解决的核心问题system_prompt 不应该是启动时的一次性快照而应该是每轮对话前后重新评估的上下文投影。换句话说prompt 的组装过程本身要工程化要能跟着会话状态走。这一章交付的东西很具体一个可复用的 system_prompt 配置骨架包含角色定义、工具约束、上下文注入三段结构一套 context 字典的维护逻辑以及在 Claude Code 里加载验证的具体动作。适合已经写过基础 Agent、正在被 prompt 膨胀困扰的 LLM Agent 开发者。下面所有代码都可以直接抄进你的项目里跑。2. 前置准备TaoToken 接入与 Claude Code 环境在动手改 system_prompt 之前得先有一个能稳定调用的模型入口。我这边用的是 TaoToken 的 API 网关它兼容 Anthropic 的 Messages 接口Claude Code 和自写的 Agent 都能直接接。你需要准备两样东西一个 API Key以及把 base_url 指向 TaoToken 的 API 地址。API Key 在控制台里生成地址是https://taotoken.net/api-keys生成后复制出来后面配置环境变量要用。环境变量这样设export ANTHROPIC_BASE_URLhttps://taotoken.net/api export ANTHROPIC_AUTH_TOKEN你的API Key注意这里有个坑如果你本地同时装了官方 Anthropic SDK它可能会优先读ANTHROPIC_API_KEY而不是ANTHROPIC_AUTH_TOKEN。稳妥的做法是在代码里显式判断如果检测到ANTHROPIC_BASE_URL被设置过就把ANTHROPIC_AUTH_TOKEN清掉避免两个 token 打架。这个逻辑在后面的完整代码里会体现。如果你更想先验证模型通不通可以直接用模型对话页面发一条消息试试地址是https://taotoken.net/models。确认能正常返回之后再进入下面的配置环节。3. 可复制的 system_prompt 配置骨架3.1 三段式结构identity / tools / context先把骨架定下来。整个 system_prompt 由三部分组成第一段是identity角色定义相对静态写死在一个PROMPT_SECTIONS字典里。第二段是tools当前启用了哪些工具从 context 里动态取。第三段是context工作目录、相关记忆、待办状态等同样从 context 里动态取。用一句话概括prompt prompt_section context。prompt_section 是静态骨架context 是动态血肉。import os import json from pathlib import Path WORKDIR Path.cwd() MEMORY_INDEX WORKDIR / .memory / MEMORY.md PROMPT_SECTIONS { identity: ( You are a coding agent. Solve tasks by reading, writing, and executing code. Prefer minimal, verifiable changes. When the user says remember, extract it as a memory. ), } TOOL_HANDLERS { read_file: lambda path: Path(path).read_text(), write_file: lambda path, content: Path(path).write_text(content) or ok, list_dir: lambda path.: \n.join(os.listdir(path)), }PROMPT_SECTIONS里目前只有 identity 一段这是故意的。后续你要加“技能说明”“安全约束”这类相对固定的段落都往这个字典里塞组装函数不用改。3.2 context 字典会话状态的快照context 是这一章的核心数据结构。它记录当前会话的状态启用了哪些工具、加载了哪些记忆、工作目录在哪。每轮工具调用结束后从最新的 messages 里推断出新的 context再重新拼 prompt。def update_context(context: dict, messages: list) - dict: 根据当前 messages 重建 context 快照。 memories if MEMORY_INDEX.exists(): content MEMORY_INDEX.read_text().strip() if content: memories content return { enabled_tools: list(TOOL_HANDLERS.keys()), workspace: str(WORKDIR), memories: memories, }这里enabled_tools和workspace目前是写死的memories会随.memory/MEMORY.md文件变化而更新。这就是骨架的意义结构先立住具体哪些字段动态化后面按需补。3.3 组装函数与缓存避免每轮重复拼组装函数接收 context返回最终 prompt 字符串。但这里有个性能细节如果 context 没变没必要每轮都重新拼一遍。所以加一层缓存用 context 的 JSON 序列化结果作为 key。_last_context_key None _last_prompt None def get_system_prompt(context: dict) - str: global _last_context_key, _last_prompt key json.dumps(context, sort_keysTrue, ensure_asciiFalse, defaultstr) if key _last_context_key and _last_prompt: print( \033[90m[cache hit] system prompt unchanged\033[0m) return _last_prompt _last_context_key key _last_prompt assemble_system_prompt(context) loaded [identity, tools, workspace] if context.get(memories): loaded.append(memory) print(f \033[32m[assembled] sections: {, .join(loaded)}\033[0m) return _last_prompt def assemble_system_prompt(context: dict) - str: sections [PROMPT_SECTIONS[identity]] tools ,.join(context.get(enabled_tools, [])) if tools: sections.append(fAvailable tools: {tools}.) sections.append(fWorking directory: {context.get(workspace, WORKDIR)}) memories context.get(memories, ) if memories: sections.append(fRelevant memories:\n{memories}) return \n\n.join(sections)json.dumps的三个参数值得说一下sort_keysTrue保证相同内容产生相同字符串不受字典顺序影响ensure_asciiFalse保留中文不转义成\uXXXXdefaultstr兜底遇到无法序列化的对象直接转字符串防止报错。3.4 agent_loop两个注入节点最后把组装逻辑接进主循环。关键只有两个节点循环开始时拿一次 prompt每轮工具调用处理完后更新 context 再拿一次。def agent_loop(messages: list, context: dict): system get_system_prompt(context) while True: response client.messages.create( modelMODEL, systemsystem, messagesmessages, toolsTOOLS, max_tokens8000, ) messages.append({role: assistant, content: response.content}) if response.stop_reason ! tool_use: return results [] for block in response.content: if block.type ! tool_use: continue print(f\033[36m {block.name}\033[0m) handler TOOL_HANDLERS.get(block.name) output handler(**block.input) if handler else fUnknown: {block.name} print(str(output)[:200]) results.append({ type: tool_result, tool_use_id: block.id, content: output, }) messages.append({role: user, content: results}) context update_context(context, messages) system get_system_prompt(context)到这里骨架就完整了。你会发现它其实很朴素甚至有点“写死”——但正是这种朴素让动态注入的边界变得清晰哪些字段该跟着会话走哪些字段暂时固定一眼能看出来。4. 在 Claude Code 中加载并验证4.1 启动与首次组装把上面的代码存成s10_system_prompt/code.py在项目根目录跑起来。第一次进入交互时你会看到类似这样的输出[assembled] sections: identity, tools, workspace, memory s10 read the file code.py in s10_system_prompt read_file[assembled]这行日志是验证的关键。它告诉你这次拼 prompt 时加载了哪几段。如果.memory/MEMORY.md存在且有内容memory会出现在列表里如果文件不存在或为空就只有前三段。4.2 缓存命中验证紧接着发第二条指令比如“列出当前目录”。如果 context 没有变化你会看到[cache hit] system prompt unchanged这说明缓存生效了没有重复组装。这一步很重要因为如果每轮都重新拼长会话下 token 消耗和延迟都会上去。4.3 记忆注入验证现在往.memory/MEMORY.md里写一行内容比如“用户偏好 Tab 缩进”。再发一条指令观察日志[assembled] sections: identity, tools, workspace, memorymemory段被重新加载了说明update_context读到了文件变化缓存 key 变了触发了重新组装。你可以让模型复述一下当前 system_prompt 里包含哪些记忆确认注入真的生效。4.4 工具约束验证把TOOL_HANDLERS里删掉一个工具比如去掉write_file重启后再发指令。日志里的tools段会少一个工具名模型也不会再尝试调用被删掉的工具。这就是工具约束段的作用prompt 里声明了什么模型就只在这个范围内行动。5. 本篇常见错排查报错一ANTHROPIC_AUTH_TOKEN和ANTHROPIC_API_KEY冲突返回 401。原因通常是本地同时存在两个环境变量SDK 读了错误的那个。解决方式是在代码入口处显式清理if os.getenv(ANTHROPIC_BASE_URL): os.environ.pop(ANTHROPIC_AUTH_TOKEN, None)注意这行要放在load_dotenv之后、创建 client 之前。报错二json.dumps抛TypeError: Object of type XXX is not JSON serializable。说明 context 里混进了自定义对象。检查update_context的返回值确保所有字段都是字符串、列表或字典。如果确实需要放对象靠defaultstr兜底但更推荐在源头就转成可序列化类型。报错三日志一直显示[assembled]从不[cache hit]。说明 context 每轮都在变。最常见的原因是update_context里返回了带时间戳或随机数的字段或者memories每次读文件都带上了不同的空白字符。用strip()清理并检查是否有字段在无意义地变化。报错四模型不调用工具直接返回文本。先看日志里tools段有没有正确列出工具名。如果enabled_tools为空assemble_system_prompt会跳过工具段模型自然不知道有工具可用。检查TOOL_HANDLERS是否在update_context之前就定义好了。报错五改了PROMPT_SECTIONS但行为没变。缓存 key 只基于 context不包含PROMPT_SECTIONS。如果你改了静态段落需要重启进程或者把PROMPT_SECTIONS的版本号也纳入 key 的计算。6. 下一步把骨架接进你的真实项目这套骨架跑通之后你会发现它最大的价值不是代码本身而是把“prompt 里该放什么”这个问题从玄学变成了工程问题。identity 段回答“我是谁”tools 段回答“我能用什么”context 段回答“我现在知道什么”。三段各司其职动态的部分走 context静态的部分走 PROMPT_SECTIONS。如果你打算长期做编码类 Agent建议把 API Key 和接入配置固定下来用 Coding Plan 这类长期方案管理调用额度地址是https://taotoken.net/coding-plan。接入文档在https://taotoken.net/doc里面有 Messages 接口的完整参数说明对照着调max_tokens和tools字段比较方便。后续章节大概率会继续补update_context里那些写死的字段——比如让enabled_tools跟着技能加载动态变让workspace跟着子 Agent 切换。骨架已经立住了往里填肉就是时间问题。
网站建设高端定制企业官网