手搓Claude Code-第十章 system_prompt
写在前面
例如:还记得上一章末尾那个有关prompt的问题吗?紧接着本章就是在解决它。如果你写过最简单的 LLM Agent,你的system prompt大概长这样:
python
SYSTEM = f"You are a coding agent at {os.getcwd()}. Use bash to solve tasks."
但随着你给它加越来越多的能力------读文件、写文件、子 Agent、技能、记忆、压缩------这个字符串开始膨胀:
python
SYSTEM = (
f"You are a coding agent at {WORKDIR}.\n"
f"Available skills:\n{catalog}\n"
f"Available memories:\n{index}\n"
f"Current todos:\n{todos}\n"
f"Relevant memories:\n{injected}\n"
"Respect user preferences from memory.\n"
"When the user says 'remember', extract it as a memory.\n"
# ... 还在继续加
)
问题来了:这个字符串只在程序启动时拼一次,有的prompt我们真的需要吗?。
- 用户第一句话是"帮我格式化代码",你注入了"用户偏好 Tab 缩进"的记忆。但用户第二句话是"顺便读一下 README",跟缩进毫无关系------那段记忆还赖在 prompt 里占 token。
- 用户中途加载了一个 skill,prompt 里没有这个 skill 的说明。
- 用户做完了 todo 的前两项,prompt 里还显示"5 项待办全 pending"。
其实本章的大部分代码在结局什么是硬编码什么是软编码的问题,而这中间的界限比较模糊 。这章的内容不多,但笔者却在这部分花费了相当时间去理解。详细完整代码见:
https://github.com/shareAI-lab/learn-claude-code/blob/main/s10_system_prompt/code.py
本章,我们的任务是:
1,了解prompt的动态注入,动手实现。
2,跑几个任务测试。
一、了解prompt的动态注入,动手实现。
动态 prompt 注入的思想很简单:不是在启动时拼一次就完事,而是每轮对话前后都重新评估一次上下文,再拼 prompt。
本章的核心在于新引入了一个context字典: context 这个字典------它是当前会话状态的快照:启用了哪些工具、加载了哪些记忆、当前有什么任务、用户说了什么偏好。每轮工具调用后,从最新的 messages 里推断新的 context,再重新拼 prompt。整个过程其实就是在维护这个context的过程。
python
def agent_loop(messages: list, context: dict):
# 首先拿到系统提示词
system = get_system_prompt(context)
while True:
# 建立会话
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
messages.append({"role": "assistant", "content": response.content})
# 不是工具调用的response直接返回。(其他功能全部删去)
if response.stop_reason != "tool_use":
return
# 是工具调用,对content中的block依次进行处理
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 f"Unknown: {block.name}"
print(str(output)[:200])
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
# 处理完content,更新context,然后再拿一次系统提示词。
context = update_context(context, messages)
system = get_system_prompt(context)
ok,接下来,我们就来一一实现agent_loop中的操作。首先是get_system_prompt() ,这个函数传入是为了传入context拿到动态注入的prompt,也就是_last_prompt。它其实也section。在该章节当中section由prompt_section和context构成。看到这里你可能还是不太理解,没关系继续往下看。
python
# 存储上一次的上下文
_last_context_key = None
# 存储上次生成的提示词
_last_prompt = None
def get_system_prompt(context: dict) -> str:
"""接收context,返回一个str。"""
global _last_context_key, _last_prompt
"""
转化成json格式的字符串
sort_keys=True:按 key 排序,保证相同内容产生相同字符串(顺序无关)
ensure_ascii=False:保留非 ASCII 字符(如中文),不转义成 \uXXXX
default=str:遇到无法序列化的对象(如自定义类),直接用 str() 转换,防止报错
"""
key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)
# 如果key和上次一样,并且lastprompt也存在就直接返回上次的提示词即可
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)
# 打印日志,是动态注入的内容,内容是固定的,只是为了反应有无memory的区别。
loaded = ["identity", "tools", "workspace"]
if context.get("memories"):
loaded.append("memory")
print(f" \033[32m[assembled] sections: {', '.join(loaded)}\033[0m")
return _last_prompt
再来实现assemble_system_prompt(context),这个函数是传入当前的context,结合PROMPT_SECTIONS中的内容去生成对应的section,也就是最终拿到的prompt。
python
def assemble_system_prompt(context: dict) -> str:
"""基于当前的context筛选并加载prompt"""
sections = []
# 先加载prompt_section(从PROMPT_SECTIONS中拿到)
sections.append(PROMPT_SECTIONS["identity"]) # 目前是写死的,但只需要补充一个函数应该就可以更新
# 再加载动态的工具(从context中拿到,下面三个都是)
tools = ','.join(context.get("enabled_tools", []))
if tools:
sections.append(f"Available tools: {tools}.")
# 再加载动态的工作区
sections.append(f"Working directory: {context.get("workspace", WORKDIR)}")
# 最后加载相关的memory
memories = context.get("memories", "")
if memories:
sections.append(f"Relevant memories:\n{memories}")
# 返回sections
return "\n\n".join(sections)
到现在,我们明白了,其实整个prompt = section = prompt_section + context。prompt_section和context都是动态的,所以我们还要写一个update_context(context: dict, messages: list),这个函数会根据上次的context和当前messages去更新当前的context。
python
def update_context(context: dict, messages: list) -> dict:
"""传入当前context和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, # 会更新
}
再回到agent_loop部分。我们只需要注意两个节点,一个是刚开始,一个是结束时。
python
def agent_loop(messages: list, context: dict):
# 首先拿到系统提示词
system = get_system_prompt(context)
while True:
# 建立会话
response = client.messages.create(
model=MODEL, system=system, messages=messages,
tools=TOOLS, max_tokens=8000)
messages.append({"role": "assistant", "content": response.content})
# 不是工具调用的response直接返回。(其他功能全部删去)
if response.stop_reason != "tool_use":
return
# 是工具调用,对content中的block依次进行处理
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 f"Unknown: {block.name}"
print(str(output)[:200])
results.append({"type": "tool_result",
"tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
# 处理完content,更新context,然后再拿一次系统提示词。
context = update_context(context, messages)
system = get_system_prompt(context)
到这里,就可以看出来,其实shareai给的这一章更像一种骨架或者示例,展示了一种动态注入的方式,但封装到最底层还是写死的。
二、跑几个测试
bash
s10 >> read the file code.py in s10_system_prompt
[assembled] sections: identity, tools, workspace, memory
> read_file
import os, subprocess, json
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
except ImportError:
pass
from anthropic import Anthrop
[cache hit] system prompt unchanged
Here's the content of `s10_system_prompt/code.py`:
```python
import os, subprocess, json
from pathlib import Path
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
...此处省略
This is an enhanced version of the coding agent that introduces **dynamic system prompt assembly** with caching. Key additions compared to earlier versions:
- **`PROMPT_SECTIONS`** --- a dict of static prompt sections (identity).
- **`assemble_system_prompt(context)`** --- builds the system prompt by combining identity, available tools, workspace path, and any relevant memories from the `.memory/MEMORY.md` file.
- **`get_system_prompt(context)`** --- caches the assembled prompt; returns the cached version if the context hasn't changed (avoids re-assembling on every API call).
- **`update_context(context, messages)`** --- rebuilds the context dict (tools, workspace, memories) after each agent loop iteration, so the system prompt can change dynamically (e.g., if new memories are added).
只需要观察assembled sections: identity, tools, workspace, memory有没有出现。这些也是固定的,我在上面对应的代码中也标注出来了。
总结
总结来说,这一章还是一种用于示范的骨架,后续章节应该还会针对这些没有完成的部分进行补充。