总结
第 10 课的重点不放在“怎么把几段字符串拼起来”。
真正的重点是:
Agent 每次工作前,都要先根据真实现场,生成一份当前可用的上岗说明。这份上岗说明就是 system prompt。
它告诉模型:
- 你是谁。
- 当前有哪些工具。
- 你在哪个目录工作。
- 当前项目有没有 Skills。
- 当前项目有没有长期 memory。
前几课直接写一段固定 prompt 还能跑。到了第 10 课,Agent 已经有工具、权限、Hooks、Todo、子代理、Skills、上下文压缩和长期记忆。
继续手写一整段大字符串,会让 system prompt 变成一份“没人敢改的手册”。
这一课的架构思想很简单:
真实状态决定 prompt 内容
prompt 内容分段管理
状态没变就复用上一次结果学这节,先不用背 stableSerialize() 怎么写。
真正要带走的是:Agent 运行系统里,prompt 也应该像配置、路由、工具表一样,由运行时状态驱动。
重点
- System prompt 是 Agent 每次上岗前的规则说明。
- 固定大字符串适合早期 demo,不适合能力越来越多的 Agent。
- prompt 应该由
真实状态生成:- 当前工作目录是什么。
- 当前真正注册了哪些工具。
- 当前项目有哪些 Skills。
- 当前有没有 memory 索引。
- prompt 应该分段:
identity管身份。tools管工具和技能。workspace管工作目录。memory管长期记忆索引。
- 缓存比较的是 Prompt Context。
- 工作现场没变,system prompt 可以复用。
- 对话消息变了,不代表 Prompt Context 一定变了。
一个插曲
现在写程序,知道每个函数细节当然有用,但价值已经没以前那么高。
AI 可以很快生成实现。更重要的是,你能不能判断:
- 哪些东西应该写死。
- 哪些东西应该来自运行时状态。
- 哪些规则应该拆成独立模块。
- 哪些内容应该进入本轮请求。
- 哪些内容可以复用。
第 10 课想训练的就是这种架构判断。 把 system prompt 当成一份固定文案,就会不断堆字。 把 system prompt 当成运行时产物,就会自然出现这条链路:
现场状态 -> 材料清单 -> 分段选择 -> 生成 prompt -> 发给模型这才是本课核心。
架构总览:System Prompt 组装器夹在真实状态和模型请求之间
这张图里有两个重点:
- Prompt 组装层不执行工具。
- 它只负责把真实状态变成 system prompt。
- 工具执行层不决定 prompt 内容。
- 它只负责把模型要做的事落到本机。
为什么固定大字符串已经不够用
一句话:
Agent 能力越多,固定 prompt 越容易和真实现场脱节。具体就三个问题。
- 换项目时,不知道该改哪里。
- 工作目录可能变了。
- 工具可能变了。
- Skills 可能变了。
- memory 可能变了。
- 修改一处时,容易影响全局。
- 工具规则、目录规则、记忆规则都混在一起。
- 加一段说明,可能和前面的说明打架。
- 每轮都带全部内容,浪费 token。
- 当前没有 memory,就不该塞 memory 说明。
- 当前工具不可用,就不该告诉模型可以用。
所以这一课不追求“把 prompt 写得更长”。
它追求的是:
该出现的出现
不该出现的别出现
每段规则都有归属这一节也可以画成一个更短的判断图:
整体流程
这张图里最重要的是上半段。
工具调用、Hooks、memory 都是前几课已经有的能力。
第 10 课只改变一件事:
每次请求模型前,system prompt 先由真实状态组装出来。一条任务贯穿全文
只看这一条命令:
hx agent "读取 package.json,只告诉我项目 name。这个任务只需要一次 read_file,不要使用其他工具。"它会经历两轮模型调用。
第一轮:
HarnessX 组装 system prompt
-> DeepSeek 看到用户任务
-> DeepSeek 返回 read_file 工具调用
-> HarnessX 在本机读取 package.json
-> 工具结果回填 messages第二轮:
HarnessX 再次准备请求
-> Prompt Context 没变
-> system prompt 命中本地缓存
-> DeepSeek 看到 package.json 内容
-> 回答项目 name 是 harnessx终端里要盯这几行:
# 第一轮:真实状态生成了一份新 system prompt
[PROMPT] assembled sections=identity,tools,workspace,memory
# 模型请求 HarnessX 调用本地工具
> read_file path=package.json limit=all
# 第二轮:prompt 材料清单没变,复用上一次结果
[PROMPT] cache hit
# 模型根据工具结果回答
项目 name 是 harnessx。一个困惑的补充
这里很容易疑惑:
读完 package.json 后,文件内容已经加入对话了。
为什么还说 Context 没变?因为这里有两种上下文。
- 对话上下文变了。
messages多了 assistant 的工具调用。messages又多了read_file返回的文件内容。
- Prompt Context 没变。
- 工作目录没变。
- 工具表没变。
- Skills 目录没变。
- memory 索引没变。
所以第二轮真实发生的是:
messages 变了
-> 第二次请求会带上 package.json 的内容
Prompt Context 没变
-> system prompt 不用重新拼[PROMPT] cache hit 只看 Prompt Context,不看整份 messages。
可以把它画成两条轨道:
这张图可以直接解释:
messages 变了,所以第二轮模型能看到 package.json。
Prompt Context 没变,所以 system prompt 可以复用。发给 DeepSeek 的一来一回
第 10 课没有改变 DeepSeek API 的形状。
改变的是 messages 里那条 role: system 的来源。
请求体仍然是 Chat Completions:
{
model: "deepseek-chat",
stream: false,
messages: [
{
role: "system",
content: "运行时组装出来的 system prompt"
},
{
role: "user",
content: "读取 package.json,只告诉我项目 name..."
}
],
tools: [
// bash、read_file、write_file、todo_write、task、load_skill、compact...
],
tool_choice: "auto"
}DeepSeek 自己不能打开本机文件。
它只能返回工具调用意图。真正读文件的是 HarnessX。
模型说:我要调用 read_file
HarnessX 做:在本机读 package.json
模型再看:工具结果已经回填到 messages三个架构判断
第 10 课可以压成三个判断。
Prompt 要从现场来
System prompt 不应该靠人手同步所有现场变化。
工作目录、工具表、技能目录、memory 索引,本来就是程序运行时已经知道的东西。
让程序自己收集它们,再生成 prompt,风险更小。
不要让模型相信一份过期手册。
让手册每轮都从现场生成。Prompt 要分段管理
一整段大字符串很难维护。
分段后,每段只负责一类规则:
identity -> 这个 Agent 是谁
tools -> 它能调用什么
workspace -> 它在哪工作
memory -> 它知道哪些长期索引这样修改规则时,入口更清楚。
工具规则错了,就看 tools。
目录规则错了,就看 workspace。
memory 没加载,就看 memory 是否有真实内容。
Section 的关系可以这样讲:
Prompt 要能稳定复用
同一份运行状态,应该得到同一份 prompt。
所以要给 Prompt Context 生成稳定指纹。
指纹变了:重新组装
指纹没变:复用上一次字符串这里的缓存很朴素。
它只减少本地重复拼装,不等于 DeepSeek API 级 prompt cache。
但思想是一致的:
稳定内容要稳定
变化内容要有边界怎么跑
沿用第 0 课的 npm link 和 DeepSeek 配置。
运行:
hx agent "读取 package.json,只告诉我项目 name。这个任务只需要一次 read_file,不要使用其他工具。"它能触发本课机制,因为 hx agent 每轮请求 DeepSeek 前都会执行:
收集真实状态
-> 生成 Prompt Context
-> 组装或复用 system prompt
-> 再调用 DeepSeek验收标准:
- 能看到
[PROMPT] assembled sections=...。 - 工具调用后,下一轮能看到
[PROMPT] cache hit。 - 最终回答能从
package.json里读出项目名harnessx。
如果没有出现 cache hit,先看是不是中途改变了 Prompt Context:
- 当前目录变了。
- 工具定义变了。
- Skills 目录变了。
- memory 索引变了。
如果只是 messages 增加了工具结果,cache hit 仍然应该出现。
这一课真正要记住
System prompt 不该变成一段越写越长的固定文案。
它是 Agent 每轮根据真实状态生成的上岗说明。再压缩成一句:
真实状态决定 prompt,稳定状态复用 prompt。源码
源码放最后,只看这套思想在当前版本里怎么落地。
源码大流程图
先不要看函数名。只看代码实现到底在做哪几件事:
这张图对应的是代码主流程。
它想表达的不是“哪个函数调用哪个函数”,而是:
每轮请求模型前,代码都会先把真实现场整理成系统提示词。
工具执行后,只是对话列表变了;如果现场没变,系统提示词还能复用。代码概览
入口没变。
用户仍然从 hx agent 进入:
// src/index.js
if (command === "agent") {
const task = rest.join(" ").trim();
await runAgent(task);
return;
}第 10 课真正新增的是 prompt 组装层:
// src/system-prompt.js
export function buildPromptContext(state) {
// 把真实运行状态整理成 prompt 材料清单
}
export function assembleSystemPrompt(context) {
// 根据材料清单选择 section,再拼成 system prompt
}
export function getSystemPrompt(context) {
// 材料清单没变,就复用上一轮结果
}Agent Loop 每轮调用模型前都会更新 system message:
// src/agent-loop.js
await compactBeforeModelCall(messages, stats);
updateSystemMessage(messages);
const message = await callDeepSeekMessage(messages, {
tools: TOOL_DEFINITIONS,
tool_choice: "auto",
});代码细分
先收集真实状态。
这里不读用户关键词,也不猜模型可能需要什么。
function updateSystemMessage(messages) {
const tools = TOOL_DEFINITIONS
.map((definition) => definition.function)
.filter((tool) => tool && TOOL_HANDLERS[tool.name])
.map((tool) => ({
name: tool.name,
description: tool.description,
}));
const context = buildPromptContext({
workspace: process.cwd(),
tools,
skillCatalog: SKILL_REGISTRY.listDescriptions(),
memoryIndex: readMemoryIndex(),
});
const content = getSystemPrompt(context);
const systemMessage = messages.find((message) => message.role === "system");
if (systemMessage) {
systemMessage.content = content;
return;
}
messages.unshift({ role: "system", content });
}把现场状态整理成 Prompt Context。
export function buildPromptContext({
workspace,
tools = [],
skillCatalog = "",
memoryIndex = "",
}) {
return {
workspace: String(workspace || ""),
tools: tools.map((tool) => ({
name: String(tool?.name || ""),
description: String(tool?.description || ""),
})),
skillCatalog: String(skillCatalog || "").trim(),
memoryIndex: String(memoryIndex || "").trim(),
};
}根据 Context 选择 section。
identity、tools、workspace 始终需要。
memory 只有在 memory 索引存在时才加入。
const ALWAYS_PROMPT_SECTIONS = ["identity", "tools", "workspace"];
export function assembleSystemPrompt(context) {
const loadedSections = [...ALWAYS_PROMPT_SECTIONS];
if (context.memoryIndex) {
loadedSections.push("memory");
}
return {
prompt: loadedSections
.map((sectionName) => PROMPT_SECTIONS[sectionName](context))
.join("\n\n"),
loadedSections,
};
}缓存看的是整份 Prompt Context。
let promptCacheKey = "";
let promptCacheValue = "";
export function getSystemPrompt(context) {
const nextKey = stableSerialize(context);
if (nextKey === promptCacheKey && promptCacheValue) {
console.log("[PROMPT] cache hit");
return promptCacheValue;
}
const { prompt, loadedSections } = assembleSystemPrompt(context);
promptCacheKey = nextKey;
promptCacheValue = prompt;
console.log(`[PROMPT] assembled sections=${loadedSections.join(",")}`);
return prompt;
}稳定指纹的关键是对象 key 排序。
同一份材料清单,不应该因为对象字段顺序不同就被当成新状态。
function stableSerialize(value) {
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}最后,DeepSeek 调用仍然只认 messages。
第 10 课只是保证 messages 里的第一条 system message 来自真实状态。
export async function callDeepSeekMessage(messages, options = {}) {
const requestBody = {
model: process.env.DEEPSEEK_MODEL || "deepseek-chat",
messages,
stream: false,
...options,
};
const response = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,
},
body: JSON.stringify(requestBody),
});
const payload = await response.json();
return payload?.choices?.[0]?.message;
}这就是本课完整链路:
hx agent
-> runAgent()
-> updateSystemMessage()
-> buildPromptContext()
-> getSystemPrompt()
-> role: system
-> callDeepSeekMessage()
-> tool_calls / final answer