前言
大家好啊,相信大家也已经发现了,最近暑期实习生招聘,某些大厂已经完全没有传统的前后端测开这类研发岗位,全是AI研发工程师这一类了,不知道的还以为招的全是算法岗;而且在面试的时候,也会实际考察 AI Coding 能力,八股能力反而不非常看重了。虽然不好说未来别的厂会不会进一步跟进,但是学一学总是没有坏处的,下面我也整理了一下我看了各种文章之后,整体理解的Agent,从原理,架构到最佳工程实践,希望能帮到大家~
简要说明:这篇文章将主要介绍一下,什么是 Agent,以及根据 Harness 工程,将Agent中最核心的,对整个Agent能力影响最大的部分:Agent Loop,Context Engineering,Tool,Memory,Muti-Agent,Agent Tracing & Evaluation,分别进行说明,此外还将介绍一下怎么写一个好的 Prompt,以及如何正确的写一个 Skill,全是干货~
什么是Agent?简单抽象一下Agent Loop
所谓Agent,其实就是一个能够感知环境,自主决策并采取行动以完成目标的AI系统。包含四大要素:
- 感知(Perceive):接收输入,包含文字和图片,工具返回结果等等
- 思考(Think):LLM 作为思考核心,进行推理,规划和决策
- 行动(Act):调用各类工具,执行代码,搜索,使用操作系统能力等
- 记忆(Memory):保存上下文,执行多步骤任务,中断任务恢复等等
相对普通的大模型,就是一个聊天框在那里和用户对话,Agent 最显著的特征,就是它可以调用工具去自主完成任务,我一直觉得下面这个比喻非常的贴切:
LLM是高智力但同时是高位截瘫患者,Agent则是高智力且手脚灵活的正常人
Agent 工具能力的来源
大语言模型(LLM)作为整个 Agent 系统的大脑,负责下达指令,就像人脑一样,而提供给它的各类工具(广义上的),就是 Agent 的手脚和眼睛,组装起来,才是一个正常的,有自主行动能力的人;我们开发Agent,本质上就是帮 LLM 去长出来手脚和眼睛,没有工程上提供的这些工具,模型再强,也仅仅就是一个高智力的瘫痪患者。工具设计决定了 Agent 能做什么。
那工具和LLM之间是如何进行交互的呢?
当我们说一个 AI Agent "调用了工具",这句话背后究竟发生了什么?
LLM 本质上是一个文本生成模型,它的输入是 token 序列(被处理后的自然语言),输出也是 token 序列。它没有办法直接执行代码、访问文件系统或调用 HTTP 接口。那么,工具调用是如何实现的? 答案是:通过协议约定 + 文本解析。
工程侧在调用 LLM 之前,会在 System Prompt 或特定的 API 字段中,告诉模型:"你可以使用以下工具,当你需要使用工具时,请按照这个格式输出。" 模型在生成回复时,如果判断需要使用工具,就会按照约定的格式输出一段结构化文本。工程侧拦截这段文本,解析出工具名和参数,执行工具,再把结果返回给模型,继续对话。这就是整个交互的核心闭环。
工具调用的完整流程如下所示:
用户输入
↓
工程侧构建 Prompt(包含工具定义)
↓
调用 LLM,获取模型输出
↓
解析输出,判断是否包含工具调用
↓
[是] 执行工具,获取结果
↓
将工具结果注入对话上下文
↓
再次调用 LLM,获取最终回复
↓
输出给用户
这个循环可以执行多轮,直到模型认为不再需要调用工具为止。这也就是我们说的Agent Loop的一个简单示例。
协议约定:LLM 与工程侧的契约
协议约定是整个工具调用机制的基础。工程侧需要在 Prompt 中明确告诉模型:
- 有哪些工具可用(工具名称、功能描述)
- 每个工具接受什么参数(参数名、类型、是否必填)
- 调用工具时应该输出什么格式(结构化文本的具体格式)
模型在训练或微调阶段,已经学会了识别这类指令并按格式输出。这已经是各大模型厂商在Raw Model层面就做好了的事情,我们使用时无需关注这里背后的内容。
目前工程实践中,主要有以下几类协议形态:
原生 Function Calling(OpenAI 风格):
OpenAI 在 API 层面提供了 tools 字段,工程侧以 JSON Schema 的形式描述工具,模型在需要调用工具时,会在响应的 tool_calls 字段中返回结构化的调用信息,而不是在文本内容中。这是目前最规范的方式,解析成本最低。
// 工程侧传入
{
"tools": [
{
"type": "function",
"function": {
"name": "run_shell", // 工具名称
"description": "在本地执行一条 shell 命令",
"parameters": { // 参数要求
"type": "object",
"properties": {
"command": { "type": "string", "description": "要执行的命令" }
},
"required": ["command"]
}
}
}
]
}
// 模型返回
{
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "run_shell", // 对应工具名称
"arguments": "{\"command\": \"ls -la\"}" // 入参
}
}
]
}
XML 标签协议
在没有原生 Function Calling 支持的场景下,或者需要更灵活控制的场景,工程侧会约定让模型用 XML 标签包裹工具调用。Anthropic 的早期版本、以及很多开源 Agent 框架都采用这种方式,比如大名鼎鼎的 Bolt.diy。
<tool_call>
<name>run_shell</name>
<parameters>
<command>ls -la /tmp</command>
</parameters>
</tool_call>
JSON 代码块协议 另一种常见方式是约定模型在 Markdown 代码块中输出 JSON:
{
"tool": "run_shell",
"parameters": {
"command": "ls -la /tmp"
}
}
ReAct 格式协议 ReAct(Reasoning + Acting)是一种更早期的协议,模型以自然语言描述思考过程,然后用固定前缀标记动作:
Thought: 我需要查看 /tmp 目录下有哪些文件
Action: run_shell
Action Input: ls -la /tmp
工程侧通过正则匹配 Action: 和 Action Input: 来解析。
目前最常用的,就只剩下了JSON和XML了。
| 维度 | JSON | XML |
|---|---|---|
| 解析复杂度 | 低 | 中 |
| 特殊字符处理 | 需要转义 | CDATA 支持 |
| 流式解析 | 困难 | 容易 |
| 模型生成稳定性 | 较高 | 高 |
| 与现有工具链集成 | 极好 | 一般 |
一个小示例
下面是一个基于XML形式的工具调用的完整示例代码,还是比较清晰的展示了:
- 构建包含工具定义的 Prompt
- 解析模型输出中的 XML 工具调用
- 执行本地 Shell 命令
- 将执行结果注入上下文,让模型感知结果
- 简单的Agent Loop
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
// ============================================================
// 类型定义
// ============================================================
interface Message {
role: "system" | "user" | "assistant" | "tool";
content: string;
toolCallId?: string;
}
interface ToolCall {
name: string;
parameters: Record<string, string>;
}
interface ToolResult {
success: boolean;
stdout: string;
stderr: string;
exitCode: number;
}
// ============================================================
// 工具定义(用于注入到 System Prompt)
// ============================================================
const TOOL_DEFINITIONS = `
你可以使用以下工具。当你需要调用工具时,必须严格按照 XML 格式输出,不要在工具调用前后添加多余的解释。
<tools>
<div class="dm-message-tool tool-1">
<name>run_shell</name>
<description>在本地执行一条 shell 命令,返回标准输出和标准错误</description>
<parameters>
<parameter>
<name>command</name>
<type>string</type>
<required>true</required>
<description>要执行的 shell 命令</description>
</parameter>
</parameters>
</div>
</tools>
调用工具时,输出格式如下:
<tool_call>
<name>工具名称</name>
<parameters>
<参数名>参数值</参数名>
</parameters>
</tool_call>
工具执行完成后,结果会以 <tool_result> 标签的形式提供给你,你需要根据结果继续完成任务。
`.trim();
// ============================================================
// XML 解析:从模型输出中提取工具调用
// ============================================================
function parseToolCall(modelOutput: string): ToolCall | null {
const toolCallMatch = modelOutput.match(
/<tool_call>([\s\S]*?)<\/tool_call>/
);
if (!toolCallMatch) {
return null;
}
const toolCallContent = toolCallMatch[1];
const nameMatch = toolCallContent.match(/<name>([\s\S]*?)<\/name>/);
if (!nameMatch) {
return null;
}
const toolName = nameMatch[1].trim();
const parametersMatch = toolCallContent.match(
/<parameters>([\s\S]*?)<\/parameters>/
);
if (!parametersMatch) {
return { name: toolName, parameters: {} };
}
const parametersContent = parametersMatch[1];
const parameters: Record<string, string> = {};
// 提取所有参数标签
const parameterPattern = /<(\w+)>([\s\S]*?)<\/\1>/g;
let match: RegExpExecArray | null;
while ((match = parameterPattern.exec(parametersContent)) !== null) {
const paramName = match[1];
const paramValue = match[2].trim();
parameters[paramName] = paramValue;
}
return { name: toolName, parameters };
}
// ============================================================
// 工具执行:运行 Shell 命令
// ============================================================
async function executeShellCommand(command: string): Promise<ToolResult> {
try {
// 目前执行不怎么用execAsync了,用spwan的居多,这里只是为了方便
const { stdout, stderr } = await execAsync(command, {
timeout: 30000, // 30 秒超时
maxBuffer: 1024 * 1024, // 1MB 输出限制
});
return {
success: true,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: 0,
};
} catch (error: unknown) {
const execError = error as { stdout?: string; stderr?: string; code?: number; message?: string };
return {
success: false,
stdout: execError.stdout?.trim() ?? "",
stderr: execError.stderr?.trim() ?? execError.message ?? "Unknown error",
exitCode: execError.code ?? 1,
};
}
}
// ============================================================
// 工具结果格式化:将执行结果转换为模型可读的 XML
// ============================================================
function formatToolResult(result: ToolResult): string {
return `
<tool_result>
<success>${result.success}</success>
<exit_code>${result.exitCode}</exit_code>
<stdout>${result.stdout || "(empty)"}</stdout>
<stderr>${result.stderr || "(empty)"}</stderr>
</tool_result>
`.trim();
}
// ============================================================
// 模拟 LLM 调用
// ============================================================
async function callLLM(messages: Message[]): Promise<string> {
// 这里模拟模型的行为,实际应替换为 OpenAI / qwen / Claude 等 API 调用
// 例如:
// const response = await openai.chat.completions.create({
// model: "gpt-4o",
// messages: messages.map(m => ({ role: m.role, content: m.content })),
// });
// return response.choices[0].message.content ?? "";
// 模拟:第一次调用时,模型决定使用工具
const lastUserMessage = messages.filter((m) => m.role === "user").pop();
const hasToolResult = messages.some((m) => m.role === "tool");
if (!hasToolResult && lastUserMessage?.content.includes("当前目录")) {
return `
我来帮你查看当前目录的文件列表。
<tool_call>
<name>run_shell</name>
<parameters>
<command>ls -la</command>
</parameters>
</tool_call>
`.trim();
}
// 模拟:收到工具结果后,模型给出最终回复
return "根据工具执行结果,当前目录下的文件列表已经获取到了。你可以看到各个文件的权限、大小和修改时间。";
}
// ============================================================
// Agent 主循环:协调 LLM 调用与工具执行
// ============================================================
async function runAgent(userQuery: string): Promise<void> {
const messages: Message[] = [
{
role: "system",
content: TOOL_DEFINITIONS,
},
{
role: "user",
content: userQuery,
},
];
console.log("=== Agent,启动! ===");
console.log(`用户输入: ${userQuery}\n`);
const MAX_ITERATIONS = 10; // 防止无限循环
let iterationCount = 0;
while (iterationCount < MAX_ITERATIONS) {
iterationCount++;
console.log(`--- 第 ${iterationCount} 轮 LLM 调用 ---`);
// 调用 LLM
const modelOutput = await callLLM(messages);
console.log(`模型输出:\n${modelOutput}\n`);
// 将模型输出加入对话历史
messages.push({
role: "assistant",
content: modelOutput,
});
// 尝试解析工具调用
const toolCall = parseToolCall(modelOutput);
if (!toolCall) {
// 没有工具调用,模型已给出最终回复
console.log("=== Agent 完成 ===");
console.log(`最终回复: ${modelOutput}`);
break;
}
console.log(`检测到工具调用: ${toolCall.name}`);
console.log(`参数: ${JSON.stringify(toolCall.parameters, null, 2)}\n`);
// 执行工具
let toolResult: ToolResult;
if (toolCall.name === "run_shell") {
const command = toolCall.parameters["command"];
if (!command) {
toolResult = {
success: false,
stdout: "",
stderr: "缺少必要参数: command",
exitCode: 1,
};
} else {
console.log(`执行命令: ${command}`);
toolResult = await executeShellCommand(command);
}
} else {
toolResult = {
success: false,
stdout: "",
stderr: `未知工具: ${toolCall.name}`,
exitCode: 1,
};
}
console.log(`工具执行结果:`);
console.log(` 成功: ${toolResult.success}`);
console.log(` 退出码: ${toolResult.exitCode}`);
console.log(` 标准输出: ${toolResult.stdout}`);
console.log(` 标准错误: ${toolResult.stderr}\n`);
// 将工具结果注入对话上下文,让模型感知执行结果
const formattedResult = formatToolResult(toolResult);
messages.push({
role: "tool",
content: formattedResult,
});
console.log(`已将工具结果注入上下文:\n${formattedResult}\n`);
}
if (iterationCount >= MAX_ITERATIONS) {
console.warn("达到最大迭代次数,Agent 强制终止");
}
}
// ============================================================
// 入口
// ============================================================
runAgent("帮我查看当前目录下有哪些文件").catch(console.error);
这里也可以比较明显的看出来,看似与Agent交流时,你只发出了一句话,实际上,LLM已经接收到了多次的指令,但是在你看起来,就是一次任务执行,这里的控制就是依赖工程侧的能力;此外,这里的历史状态的维护,也就是我们后面要说到的上下文工程和记忆系统的内容了。
模型感知工具结果的关键
通过上面的示例代码,我们可以看到,工具结果注入上下文后,模型在下一轮调用时,会把这段内容作为对话历史的一部分读取。这就是模型"感知"工具执行结果的本质:不是实时感知,而是通过上下文传递。
这里需要着重提一下几个工程实践要点:
- 结果格式要清晰:模型需要能从结果中理解执行是否成功、输出是什么。结构化的 XML 或 JSON 比纯文本更可靠。
- 错误信息要完整:当工具执行失败时,stderr 和 exitCode 都应该传给模型,让它能判断是否需要重试或换一种方式。
- 输出长度要控制:Shell 命令可能输出大量内容,需要截断或摘要后再注入,避免超出模型的上下文窗口。
- 多轮工具调用:模型可能在一次任务中连续调用多个工具,每次都需要把结果注入后再继续,这就是 Agent 主循环存在的意义,也是看起来整体就是一次会话的关键。
工具设计的成长
Agent 的工具设计并非一开始就有清晰的方法论,而是随着工程实践的深入逐步成熟的。
从"系统视角"到"Agent 视角"
早期(2022年末–2023年),工具设计的主流做法是 API 封装(API Wrapping) :系统能做什么,就把什么暴露出来。这种做法的问题在于,工具粒度细、数量多,LLM 需要自行规划完整的调用链,上下文压力大,出错率高。
而后,Princeton 的 SWE-agent 论文提出了 Agent-Computer Interface(ACI) 的概念,核心是一次视角转换:工具不应该以系统为中心设计,而应该以 Agent 为中心设计。具体来说,工具的命名和描述要贴近 LLM 的语言理解习惯,粒度要与 Agent 的目标动作对齐,错误反馈也要主动设计成 LLM 能够理解并自我纠正的格式。论文的实验也表明,在其任务设定下,ACI 设计对完成率的影响十分显著——工具界面的质量本身就是一个关键变量。
工程实践的进一步深化
视角转换之后,工程上还有一批具体问题需要解决。Anthropic 在其技术文档中将这一阶段的实践归纳为 Advanced Tool Use,主要包括三个方向:
- Tool Search(动态工具发现) :不再把全量工具塞入上下文,而是按需加载,降低噪声与开销;
- Programmatic Tool Calling(代码化调用) :让 LLM 用代码而非多轮对话来编排工具调用,利用循环、条件、变量复用等逻辑结构,让中间工具的执行过程被代码执行屏蔽,不进入 LLM 上下文进行干扰,减少累积误差;
- Tool Use Examples(调用示例) :在工具描述中附带真实调用示例,弥补 JSON Schema 只能描述结构约束、无法传达典型用法和上下文语义的不足。
详细说说ACI,工具设计的原则
命名与描述贴近 LLM 的语言习惯
工具的名称和描述是 LLM 选择工具的主要依据。命名要语义直观,描述要说清楚什么时候用、怎么用、会返回什么,而不是简单复制底层 API 的字段名。
// 垃圾设计
const tool = {
name: "fs_op",
description: "Perform file system operation.",
parameters: {
op: { type: "string" },
p: { type: "string" },
d: { type: "string" },
},
};
// 好设计
const tool = {
name: "read_file",
description:
"读取指定文件的内容并返回文本。适用于查看源码、配置文件等场景。文件不存在时返回错误信息而非抛出异常。",
parameters: {
file_path: {
type: "string",
description: "文件的绝对路径或相对于工作目录的路径,例如 ./src/main.ts",
},
},
};
粒度与 Agent 的目标动作对齐
工具粒度不是越细越好,也不是越粗越好,而是要与 Agent 实际需要完成的一个完整动作对齐。
反例(粒度过细):
// 三个工具分别做三件事,LLM 必须自己规划调用顺序
async function openFile(path: string): Promise<FileHandle> { ... }
async function readLines(handle: FileHandle, start: number, end: number): Promise<string[]> { ... }
async function closeFile(handle: FileHandle): Promise<void> { ... }
正例(粒度对齐):
async function readFile(
filePath: string,
options?: { startLine?: number; endLine?: number }
): Promise<string> {
/**
* 读取文件内容。
* - 不传 options 时返回全文;
* - 传入 startLine/endLine 时只返回指定行范围,适合大文件局部查看。
* 文件的打开与关闭由工具内部处理,调用方无需关心。
*/
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n");
if (options?.startLine !== undefined && options?.endLine !== undefined) {
return lines.slice(options.startLine - 1, options.endLine).join("\n");
}
return content;
}
主动设计错误反馈,让 LLM 能自我纠正
这是 ACI 与普通 API 设计最大的区别之一。工具应该捕获错误,并以结构化、可理解的方式返回,告诉 LLM 哪里错了、应该怎么修正。
反例(直接抛异常)
async function readFile(filePath: string): Promise<string> {
// 直接抛出异常,LLM 收到的是一堆 stack trace,难以提取有效信息
return await fs.readFile(filePath, "utf-8");
}
正例(结构化错误反馈):
async function readFile(filePath: string): Promise<string> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return (
`错误:'${filePath}' 是一个目录,不是文件。\n` +
`建议:如需查看目录内容,请调用 listFiles('${filePath}')。`
);
}
return await fs.readFile(filePath, "utf-8");
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") {
return (
`错误:文件 '${filePath}' 不存在。\n` +
`建议:请先调用 listFiles(directory) 确认文件路径是否正确。`
);
}
return `错误:读取文件时发生未知错误,详情:${(e as Error).message}`;
}
}
错误信息直接告诉 LLM 下一步该调用什么,形成自我纠正的闭环。
工具之间保持边界清晰,无功能重叠
如果两个工具的功能存在重叠,LLM 在选择时会产生困惑,导致不稳定的调用行为。每个工具应该有且只有一个明确的职责。
反例(职责重叠):
async function searchFile(keyword: string): Promise<string[]> { ... }
async function grepCodebase(pattern: string): Promise<string[]> { ... }
async function findInProject(query: string): Promise<string[]> { ... }
// 三个工具功能高度相似,LLM 不知道该选哪个
正例(职责清晰):
// 职责一:在单个文件内搜索
async function searchInFile(
filePath: string,
keyword: string
): Promise<string[]> { ... }
// 职责二:在目录范围内跨文件搜索
async function searchInDirectory(
dirPath: string,
keyword: string,
filePattern: string = "*.ts"
): Promise<string[]> { ... }
两个工具的适用范围互补且不重叠,选择依据一目了然。
工具尽量设计为幂等
幂等意味着同一个调用执行多次,结果与执行一次相同。对 Agent 来说,幂等工具在出错重试时更安全,状态管理也更简单。
反例(非幂等,重试有风险):
async function appendToFile(filePath: string, content: string): Promise<void> {
// LLM 重试时会重复追加,产生脏数据
await fs.appendFile(filePath, content, "utf-8");
}
正例(幂等,可安全重试):
async function writeFile(filePath: string, content: string): Promise<string> {
/**
* 将内容写入文件(覆盖写)。
* 文件不存在则创建,已存在则覆盖全文。
* 多次调用结果一致,可安全重试。
*/
await fs.writeFile(filePath, content, "utf-8");
return `文件 '${filePath}' 写入成功。`;
}
如果确实需要追加语义,应在描述中明确警告 LLM 该工具非幂等,需谨慎调用。
小小总结
这五条原则的共同出发点是:把 LLM 当作工具的真正用户来设计,而不是把工具设计成给人看的 API 文档。
| 原则 | 核心问题 | 设计目标 |
|---|---|---|
| 命名与描述 | LLM 能否选对工具 | 语义直观,说清楚用途和返回值 |
| 粒度对齐 | LLM 调用几次能完成任务 | 一个工具对应一个完整动作 |
| 错误反馈 | LLM 出错后能否自我纠正 | 返回可读错误 + 纠正建议 |
| 边界清晰 | LLM 选工具时是否会困惑 | 无功能重叠,职责唯一 |
| 幂等性 | LLM 重试时是否安全 | 多次调用结果一致 |
小总结
LLM 与工具的交互,本质上是一套文本协议 + 解析执行 + 结果回注的工程机制。模型不直接执行任何操作,它只负责按照约定的格式输出意图;工程侧负责解析意图、执行操作、感知结果,并通过上下文把结果告知模型。
这套机制看起来简单,但在实际工程中,协议的稳定性、解析的容错性、工具的安全边界、上下文的管理策略,都是需要认真对待的工程问题。理解这个底层机制,是构建可靠 Agent 系统的起点。
Agent Loop
在上面我们其实已经看到了Agent Loop,也就是 Agent 主循环的概念和简单例子,这里我们再将其抽象一下,其实就是这样的四步:感知 -> 决策 -> 行动 -> 反馈;
简化一下上面的工具调用里写的那个Agent Loop,本质上就是这么几行:
const messages: Message[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userQuery },
];
while (true) {
const output = await callLLM(messages);
messages.push({ role: "assistant", content: output });
const toolCall = parseToolCall(output);
if (!toolCall) return output; // no tool_use → final answer
const result = await executeTool(toolCall);
messages.push({ role: "tool", content: formatToolResult(result) });
}
无论各种新概念怎么加,什么Skill乱七八糟的,循环本身非常的稳定,哪怕是从这个最小实现一路升级到支持上下文压缩+Sub Agent+Skill,循环里面的东西都不会动。新加的能力都加在了循环的外面。
扩展能力的方式只有三种:注册新工具和对应的 handler、修改系统提示、把需要持久化的状态移到外部存储。循环体本身不应该承担状态管理的职责——模型负责推理和决策,状态与边界交给外部系统。分工一旦清晰,核心循环就趋于稳定,几乎不需要随能力变化而改动。
Agent只要里面的模型真的越贵它就越好吗?Harness说:“我不这么觉得。”
Harness(马具/挽具)是最近被讨论的比较多的一个概念,说白了,它就是套在 LLM 外面的"执行环境"。
其实通过上面的说明我们也能看出来了,对于一个Agent而言,脑子好用固然重要,但是决定这个Agent好不好用的一个最重要的因素,就是它够不够稳定,具体来说,就是在复杂任务执行时够不够准确,在执行中遇到错误时,能不能正确处理,边界情况能不能保证。Agent 在单次执行时可以成功,但在真实业务中多次运行结果迥异、边界情况下彻底失控。这不是模型不够聪明,而是缺乏对行为的有效约束,也就是缺少一个合适的,能完美控制它的Harness(马具/挽具)。
Harness具体包含什么
| 模块 | 作用 |
|---|---|
| 工具注册与调用 | 搜索、代码执行、数据库查询 |
| 状态与记忆管理 | 保存对话历史、中间变量 |
| 循环控制 | 控制执行步数、设置终止条件 |
| 错误处理与重试 | 工具失败后如何恢复 |
| 多 Agent 编排 | 调度子 Agent、汇总结果 |
| 安全防护 | 防止越界操作、上下文压缩 |
显而易见,这也是我们 Agent 需要的核心的能力。这个概念要求了:
- 从文本到系统:约束不再靠提示词,而是嵌入系统架构,用代码和运行时控制实现。
- 从指令到环境:不是"让 AI 按指令做",而是"AI 只能在环境里做"。
- 从禁止到无法发生:不是"禁止 AI 做错",而是"让 AI 根本做不了错误的事"。
这里着重讲一下第三条,之前可能在约束Agent的行为时,更多的是在提示词中给出它各种边界,要做以及不要做,但是随着任务执行,上下文的累加,LLM有可能发生注意力偏移,之前明明说明了的,不要做的事情,发现它又做了起来,要做的反而又忘了,尤其是一些编码的规范,这里就有一个比较好的实践:与其在提示词中声明,不如直接写在Linter或者hook中,强制约束要做和不要做,并且不仅给出报错,还要给出如何修正,比如用下面这段ESLint自定义规则来限制 Agent 不在代码中使用console.log:
// rules/no-console-use-logger.js
module.exports = {
meta: {
type: "suggestion",
fixable: "code", // 支持自动修复
messages: {
noConsole:
// ⬇️ 这条 message 就是给 Agent 看的修正提示
"禁止直接使用 `{{method}}`。\n" +
"请改用统一的 logger 模块:\n" +
" import { logger } from '@/utils/logger';\n" +
" logger.{{loggerMethod}}(...);\n" +
"原因:console 输出会混入生产日志,logger 支持级别控制和结构化输出。",
},
schema: [],
},
create(context) {
// console 方法 → 对应 logger 方法的映射
const methodMap = {
log: "info",
warn: "warn",
error: "error",
debug: "debug",
info: "info",
};
return {
MemberExpression(node) {
if (
node.object.name === "console" &&
methodMap[node.property.name]
) {
const method = node.property.name;
const loggerMethod = methodMap[method];
context.report({
node,
messageId: "noConsole",
data: { method: `console.${method}`, loggerMethod },
// 自动 fix:直接替换调用
fix(fixer) {
return fixer.replaceText(node, `logger.${loggerMethod}`);
},
});
}
},
};
},
};
这样一来,在提交代码时,只要配合Git Hook,Agent写出来有问题的代码想提交也提交不了,只能老老实实去检查报错,并发现报错原因,根据指引去修复,杜绝了 Agent 忘记约束的情况。
总体而言,Harness 不提供智能,它只是让智能能够稳定运行,是让 Agent 从“能做“到”稳做“的系统基层基础设施。
上下文工程才是保证Agent稳定第一要素
目前几乎所有常见的LLM的底层都是Transformer,最重要的肯定就是它的注意力机制,注意力机制的复杂度是 ,这就注定了随着上下文增长,关键信息得到的注意力将被各种无用的噪声信息稀释,这也就是为什么,随着任务执行,Agent的决策质量会逐渐下滑,这类现象通常被叫作Context Rot(上下文腐烂),Agent使用过程中,很多看起来像模型能力不足的问题,其实背后的元凶总是上下文组织不当。
怎么写一个好的Prompt
这里插播一下,怎么样去写一个好的Prompt,因为上下文工程,本质上就是在做 Prompt 工程。因为 Prompt 是与 LLM 交互的唯一途径——在 Agent 体系中,除了通过工具扩展模型的行动边界之外,所有对模型行为的影响和干预,最终都要落回到 Prompt 上来。所以,写好 Prompt,不是一个技巧问题,而是一个工程问题。
先理解:Transformer 注意力的"记忆偏好"
想象你在读一份很长的文件:
- 开头:你最专注,印象最深
- 中间:容易走神,细节容易忘
- 结尾:刚读完,还留在脑子里
Transformer 的注意力机制有类似的规律:
注意力权重分布(示意)
开头 ████████████ <- 权重高(位置编码强,语义锚定)
中间 ████░░░░░░░░ <- 权重衰减(容易被稀释)
结尾 ████████░░░░ <- 权重较高(proximity effect)
明显看到,开头和结尾才是Prompt的黄金地段,尤其是开头,会成为整个生成过程的"语义锚",影响模型对后续所有内容的理解方式。那么什么是好的Prompt?它应该有这样的特征:
- 明确利用到了注意力权重的分布机制,头部和尾部放置核心信息,中间放置相关的上下文补充;
- 信息密度高,没有冗余:不要像这样:"你好,麻烦你帮我看一下,我有一个问题想请教你, 就是关于 React 的,我最近遇到了一些困难...",对于和人交流这挺好的,但是和LLM交流时,你的客气礼貌只会成为分散它注意力的飞虫;
- 结构清晰,边界分明:不要这样:“你是前端工程师帮我分析一下我们项目用的React18 日活50万大促的时候会卡顿帮我优化”;
简单可以整理为以上三点,这样形成一个简单的优秀三段式的Prompt(角色定义+背景+任务要求):
# 角色
你是有10年经验的前端高级工程师,
擅长 React 性能优化与 JavaScript 异步编程,
回答注重实际工程权衡。
# 背景
项目:电商 Web App,日活50万,React 18 + TypeScript
问题:大促期间页面频繁重渲染,用户感知明显卡顿
定位:Performance 面板显示多个子组件触发不必要的 re-render
现状:组件间通过 props 传递回调函数,尚未做任何 memo 处理
# 任务
1. 分析不必要 re-render 的根本原因
2. 给出 memo / useCallback / useMemo 的优化方案
3. 提供关键代码示例,注释用中文
4. 不要改动“request-core.ts”中的任何代码
篇幅500字内,代码简洁可直接运行。
我一开始也以为开头写上角色没什么用,但实践下来,效果确实有可感知的提升。要理解背后的原因,需要先了解 LLM 的工作方式。
我们输入的文字,会先被拆分成一个个 Token(可以理解为词或子词),每个 Token 经过模型内部的 Embedding 层,被映射为一个高维向量,再经过多层 Transformer 的处理,最终形成携带上下文语义信息的表示。这些向量存在于一个巨大的高维语义空间中,语义越相近的内容,向量的方向就越接近,余弦相似度越高。
需要特别说明的是:这些向量的每个维度没有人类可以直接解读的固定含义(比如"第1维 = 红色"),语义是由所有维度共同分布式地编码的,是模型在海量数据中自动学习出来的结果,这一点和人工设计特征有本质区别。
角色定义的作用,正是在这个语义空间中提供一个上下文锚点。它作为 Prompt 的一部分输入模型后,会影响模型对后续每个 Token 的条件概率分布——相当于在概率空间中施加了一个方向性的偏置,引导模型优先激活与该角色相关的知识、语气和表达风格。角色描述越具体,这个偏置越精准,模型输出偏离预期的概率也就越低。
YY一下
那么好,这就是怎么去写一个好的上下文,其实这里说的很浅显,有人认为,更好的上下文甚至会说明执行上下文压缩时,哪些部分应该完整保留原本的内容(比如要做与不要做),哪些部分应该被完全舍弃(比如工具执行的结果)。
我个人还想像过,是不是未来面试的时候,会让你讲讲你日常工作中自己觉得,写过最好的prompt是怎么样的?或者给你一个有问题的场景,看你能不能根据问题表现,用 Agent 在最短时间内,描述清楚问题,并且修复这个问题?再或者说,给你完整的PRD/设计稿和技术文档,让你从头用Agent在最短时间内,搭建出来一个可用的实现?
上下文分层设计
好了,现在让我们回到 Agent 上下文设计本身。最近泄漏的Claude Code的源码中,上下文的设计是分层设计的,结合 LLM 本身的特点,分层设计的上下文,是目前的一个最佳实践。就像上面说的,很多情况下,Agent 中问题的来源不是模型的上下文窗口不够长,而是模型被无用的信息牵制住了注意力。再继续说下去之前,我们需要理解一个事实:
大语言模型本身没有记忆,模型权重在推理阶段是固定不变的。能实现连续对话,是因为调用方在模型外部维护了历史消息,每次调用时将历史会话和新消息拼接成完整的 Prompt 一起传给模型——模型看到的始终是"一次完整的输入",对之前的调用本身一无所知。不过这种方式受到上下文窗口长度的限制,历史消息不能无限堆叠。
所以,如果只是单纯的把所有历史上下文都塞给模型,一些只在偶然情况下才会用到的内容将每次都加载进来,稳定的规则约定和动态的工具调用信息/状态信息混杂在一起,大大稀释了上下文中的有用信息的浓度,LLM 看到的东西越来越多,但是真正有用的信息得到的注意力却越来越少。
为了解决这个问题,将上下文进行分层,按照使用频率和稳定性进行拆分,拆分为下面这几个部分,是一个比较优秀的方案:
判断一条内容该不该进上下文,核心问题只有一个:它需要模型"理解",还是只需要被"执行"? 需要模型理解的,才有资格占用 Token;能被确定性执行的,一律下沉到外部系统(正如前面提到的,用Hooks,Linter等等强制约束)。 按这个标准,信息自然分成五层:
| 层级 | 放什么 | 怎么用 | Token 消耗 |
|---|---|---|---|
| 🔒 常驻层 | 身份、约定、红线 | 每次必须在场,精简到不能再删 | 最高 |
| ⚡ 按需加载层 | Skills、领域知识 | 占位符常驻,触发时才展开全文 | 较高 |
| 🔄 运行时注入层 | 时间、渠道、用户偏好 | 每轮动态拼入,用完即走 | 中等 |
| 🧠 记忆层 | 跨会话沉淀 | 落盘到 MEMORY.md,召回时才读 | 极低 |
| ⚙️ 系统层 | Hooks、代码规则 | 交给外部执行,对模型不可见 | 零 |
越往下,越不依赖模型,也越不消耗上下文资源。
上下文压缩策略
讲到上下文工程,绕不开上下文压缩。常见策略有以下六种,先说最常见的三种:
-
滑动窗口是早期最简单的做法:当上下文超出阈值,直接截断最早的对话轮次,只保留最近的 N 条消息(系统提示通常会被保留)。逻辑简单,但代价明显——早期的任务约定、决策依据一旦被截掉就永久丢失。短对话勉强够用,遇到长任务场景基本失效。
-
LLM Summary 是更通用的方案:由一个独立调用的 LLM 按照预设规则对历史会话进行压缩。规则的核心是区分"不能动的"和"可以丢的"——角色定义、禁令、任务清单及其完成状态不能压缩;一次性的工具执行结果、过时的中间状态可以丢弃。这种方式尤其适合长任务场景,能精准保留决策路径,同时大幅缩减上下文体积。缺点是压缩质量强依赖规则设计和所选模型的能力。
-
工具结果替换针对的是大量工具调用的场景,属于工程侧的自动处理机制。其核心思路是:在上下文中预留一个固定大小的槽位,专门存放工具调用结果。所有工具共享这个槽位,新结果直接原地替换旧结果,而非追加堆叠。由于大多数工具结果只需短暂参考,不需要长期保留,这种方式能让工具调用的上下文占用保持恒定,不随调用次数增长。
这里说一下LLM压缩可能会导致的一些问题,最常见的就是该保留的没保留,不该保留的反而保留了,此外,还有标识符保留错误的问题,这里可以在Agent的约束文档中明确:
### Compact Instructions
严格依照下面的内容在压缩时保留信息:
#### 完全不压缩
1. 任务清单及其完成状态
2. 架构方案设计
3. 关键变更
4. 提供的各类标识符,如文件名,URL等
#### 可以压缩
1. 用户早期的模糊需求
2. 信息查询类工具的调用结果
3. 试错路径
#### 丢弃
1. 过时的工具调用结果,只保留执行结果
2. 对当前任务无用的冗余文件内容
这只是一个简单的示例,具体的压缩规则可以按照项目和任务的实际需要,自行进行明确。
上面三种方式是最常见的,下面的则相对复杂和底层一些:
-
RAG 检索替代注入的思路与上面几种不同,它不是在压缩已有内容,而是从源头控制注入量。其原理是:在构建知识库时,先将所有文档切片,通过 Embedding 模型将每个切片转换为向量并存入向量数据库;每轮对话时,将当前问题同样做 Embedding,转换为向量后与知识库中的所有向量做余弦相似度匹配,取相似度最高的若干片段注入上下文。相关的进来,无关的从不出现——上下文始终只包含当下真正需要的内容,而不是把整个知识库都塞进去。
-
Token 剪枝(Token Pruning) 则工作在更底层。模型在计算 Attention 时,不同 Token 对当前生成的贡献权重差异很大,剪枝机制会识别出那些权重持续偏低、对结果影响微弱的 Token,在推理过程中将其丢弃。这种方式对上层应用完全透明,压缩发生在模型内部,但对模型本身有一定要求,并非所有部署环境都支持。
-
KV Cache 复用解决的是另一个维度的问题。模型每次处理 Token 时都需要计算 Key 和 Value 矩阵,对于系统提示、固定前缀等每轮都重复出现的内容,这部分计算完全相同却每次都在重做。KV Cache 复用将这些已计算的结果缓存下来直接使用,既节省了计算开销,也间接降低了推理延迟(也即是:如果当前请求的输入前缀和之前某次请求完全一致,这部分 KV 就不需要重新计算,直接从缓存读取)。严格来说它属于推理加速而非内容压缩,但在上下文工程的整体视角下,是不可忽视的效率手段。
其中这个KV Cache 复用有点意思,基于它,有些做法对我们这些 LLM 外部的开发者而言比较有意义且可控,值得单独拎出来说说。
Prompt Caching
基于KV Cache 复用,我们可以得到一个叫做 Prompt Caching 的方法来优化我们的上下文结构,简单来说,就是依赖 KV Cache 命中,将每次请求的 Prompt 前缀完全一致,复用缓存。
这意味着在设计 Prompt 结构时,应该把稳定不变的内容(系统提示、固定知识、角色定义)放在前面,把每轮变化的内容(用户输入、动态信息)放在后面,否则前缀一变,缓存就会失效,白白浪费。
回到前面划分的分层结构:常驻层越稳定,前缀命中率就越高,边际成本也就越低。因此要求常驻层短且稳定,不只是为了节约上下文、保持模型注意力,同时也是为了保证缓存命中率。
这里也解释了 Skills 为什么要按需加载。按需注入的内容追加在稳定前缀之后,不会破坏前缀的缓存;而注入的工具定义只要自身足够稳定,同样可以参与缓存复用。反过来看,如果把大量 MCP 工具定义全部写死在系统提示里,一旦工具集发生变动,整个前缀的缓存就会失效,每次都要重新计算。
这里有一个反直觉的结论:稳定的大系统提示,实际成本往往低于频繁变动的小提示。 原因在于缓存的写入成本只付一次,后续每次调用命中缓存时读取的费用大幅折扣——以 Anthropic Claude 为例,缓存读取费用仅为原价的 10%。提示词越稳定、调用次数越多,这个收益就越显著。
Skills
什么是 Skills
Skills 是上下文工程里非常有效的一种模式,核心思路是:系统提示只保留索引,完整知识按需加载。
把每一项能力定义成一个独立的 Skill 文件,系统提示里只放一行描述符——告诉模型"有这个能力、什么时候用",完整的操作步骤、规则、示例全部放在 Skill 文件里,触发时才注入上下文。
这样做有三个直接收益:
- 节约上下文:没有被触发的 Skill,完整内容永远不占位置
- 保持缓存稳定:系统提示前缀不随能力数量增长而膨胀,缓存命中率稳定
- 易于维护:每个 Skill 独立管理,修改某一项能力不影响其他内容
Skill 是路由文件,不是功能介绍
写 Skill 最容易犯的错误,是把它写成能力介绍:
# Code Review 能力
本助手具备专业的代码审查能力,能够帮助用户发现代码中的问题,
提供优化建议,支持多种编程语言,具有丰富的工程经验……
这种写法模型能读懂,但不知道什么时候该用、用了之后该做什么。
Skill 文件应该更像一个路由文件:条件清晰、动作明确、不讲废话。模型读到它,应该能直接判断"现在该不该激活"以及"激活之后做什么"。一个好的 Skill 由四个部分构成:
① 触发条件(Trigger) :什么情况下激活,越具体越好
② 执行步骤(Steps) :有序、可执行的完整操作流程,不留模糊空间
③ 约束与边界(Constraints) :明确不做什么,防止模型越界
④ 输出格式(Output Format) :指定输出结构,让结果可预期
一个标准的 Skill.md 示例:
# Skill: Code Review
## 触发条件
用户请求涉及以下任意一项时激活:
- 代码审查 / Code Review
- Bug 定位与排查
- 性能问题分析与优化
## 执行步骤
1. 先理解代码意图,再审查实现
2. 按严重程度分级标注问题:
- `blocker`:必须修复,影响功能或安全
- `warning`:建议修复,存在隐患
- `suggestion`:可选优化,不影响运行
3. 每条问题附带具体修改建议,不只指出问题
## 约束
- 不重写整个函数,只针对具体问题给出修改点
- 不评价代码风格,除非用户明确要求
- 不在没有代码的情况下主动推断问题
## 输出格式
[blocker / warning / suggestion] 问题描述
→ 建议:具体修改方式
Skill 的分级与体积控制
Skill 文件本身也需要控制体积。常驻在系统提示里的描述符应该压缩在 1-3 行以内,只保留触发条件和能力标签,完整内容留在文件里按需加载。
同时,Skills 按使用频率分三级管理:
| 级别 | 定义 | 处理方式 |
|---|---|---|
| 常用 Skill | 几乎每次会话都会触发 | 描述符常驻系统提示,完整内容预加载 |
| 按需 Skill | 特定场景才触发 | 描述符常驻系统提示,触发时才注入完整内容 |
| 冷门 Skill | 极少触发 | 描述符也可不常驻,由用户显式召唤或工程侧条件触发 |
目标是:让高频能力随时就位,让低频能力不占位置,两者都不妥协。
在 Agent 中,触发必须由工程侧保证
Skill 在 Agent 中有一个常见误用:把 Skill 列表告诉模型,然后等它自己想起来用。
这是错误的。模型不会主动翻查自己有哪些 Skill,在多轮对话、任务复杂的情况下,早期注入的描述符很容易被后续内容稀释,注意力会漂移。
正确的做法是:每轮用户输入进来之后,工程侧强制扫描一次 Skill 列表,判断当前输入命中了哪些触发条件,将对应的完整 Skill 内容注入本轮上下文,再交给模型处理。
用户输入 → 工程侧扫描 Skill 列表 → 命中触发条件 → 注入对应 Skill → 模型处理
触发判断可以用轻量模型做语义匹配,也可以用规则或关键词粗筛。核心原则只有一条:触发这件事不能依赖主模型的记忆,必须由外部机制保证每轮执行。
脚本执行型 Skill:把工具能力内化进上下文
Skills 并不局限于纯语言任务。有一类特殊的 Skill,完整内容里直接提供可执行脚本,由 Agent 在本地环境中运行,获得与 MCP 工具类似的真实执行能力——但不需要接入任何外部服务。
# Skill: 获取系统信息
## 触发条件
用户询问当前系统状态、内存占用、磁盘空间、进程信息时激活
## 执行步骤
1. 根据用户问题判断需要哪类系统信息
2. 执行对应脚本获取数据
3. 对原始输出做简洁的自然语言解读,不直接粘贴大段原始数据
## 可用脚本
- 查看内存占用:/scripts/view-memory-percent.py
- 查看磁盘空间:/scripts/view-disk-percent.py
- 查看 CPU 负载:/scripts/view-cpu-percent.py
## 约束
- 不执行任何写操作脚本,只读取系统状态
- 脚本输出超过 20 行时,只提取关键指标汇报
## 输出格式
用一段自然语言总结当前状态,关键数字加粗标注
这类 Skill 本质上是把工具能力内化为上下文规程:脚本由 Agent 在本地执行,结果由模型解读和汇报,整个过程不依赖任何外部服务协议。
Skills 与 MCP 的关系
MCP 通过标准协议接入外部工具,让模型可以调用搜索、数据库、代码执行等真实能力。Skills 和 MCP 解决的不是同一个问题,但经常在同一个 Agent 里共存:
| 对比维度 | Skills(含脚本执行型) | MCP 工具 |
|---|---|---|
| 本质 | 上下文中的操作规程 | 外部系统的能力接口 |
| 执行方 | 模型按规程执行 / Agent 本地运行脚本 | 外部工具实际执行,模型调用 |
| 适用场景 | 推理、写作、分析、本地命令、轻量数据处理 | 远程 API、数据库、跨系统集成 |
| 上下文占用 | 描述符极小,按需注入 | 工具定义随接入数量线性增长 |
| 外部依赖 | 无 | 依赖外部服务可用性 |
| 接入成本 | 写一个 Markdown 文件 | 需要部署和维护 MCP Server |
| 安全管控 | 通过 Skill 内约束字段控制执行范围 | 依赖 MCP Server 的权限设计 |
两者不是替代关系,而是分工关系:
- 能用 Skills 解决的,不引入 MCP ——简单任务、本地环境、快速接入
- 需要对接远程服务、跨系统集成的,MCP 是更合适的选择
判断标准只有一个:这件事需要访问外部系统吗? 不需要,用 Skill;需要,用 MCP。但是,如果被对接的系统配合,也完全可以使用curl这类工具,用 Skill 的方式注入,直接调用对应接口。
文件系统和上下文,就像外存和内存
上述 Skill 的本质,是以文件系统作为中转层,实现按需加载,从而节约上下文空间。这与计算机的存储层级结构高度相似:可以将 Agent 的上下文窗口类比为 RAM——容量有限,但数据必须加载其中才能被处理;而文件系统则对应磁盘(外存) ——容量大、可持久化,数据按需读入内存。
这一思路不只适用于 Skill,同样适用于 Agent 的内置 Tool。上下文中只需保留核心能力(如读文件、执行脚本),其余工具仅保留名称与功能描述,在实际调用时再动态加载详细定义。这可以大幅降低 Token 消耗。
将信息持久化到文件系统,同样带来更强的可靠性。例如,将任务执行进度与各步骤结果记录在 progress.md 中,一旦任务中断,Agent 可以快速完成状态重建、继续执行。在上下文压缩时,也可以将压缩前的内容以结构化形式写入文件系统保存,而非直接丢弃——即便后续真的出现上下文丢失,Agent 也能从历史记录中找回关键信息,完成内容补全。
记忆系统的设计
Agent没有原生的记忆
人类的记忆是连续的。昨天发生的事,今天醒来还在。但大语言模型驱动的 Agent 不是这样工作的。
每一次会话,对 Agent 来说都是一次"全新的诞生"。上下文窗口装载了对话内容,推理在其中进行,输出生成后,会话结束——这一切随之清空。下一次启动时,Agent 不会知道上一次说了什么、做了什么、用户是谁、任务进行到哪里。
这不是 bug,这是 Transformer 架构的基本特性:模型本身无状态,状态由上下文承载,上下文不持久化,则状态不存在。
因此,要让一个 Agent 系统具备跨会话的一致性——记住用户偏好、延续任务状态、积累领域知识——记忆层必须单独设计,作为独立的基础设施存在。它不是功能迭代时可以"顺手加上"的能力,而是系统架构的地基。没有记忆层,Agent 永远是一个失忆的执行者;有了记忆层,它才能成为一个真正可信赖的协作伙伴。
四种记忆形式
记忆层并非单一结构。根据时效性、结构化程度和更新频率的不同,可以将 Agent 的记忆拆分为四个层次,各司其职。
1. 上下文窗口(Context Window)
这是 Agent 的工作记忆,也是唯一天然存在的记忆形式。
当前对话的所有内容——用户输入、Agent 的回复、工具调用结果、系统提示——都存在于上下文窗口中。模型的每一次推理,都以此为全部的"现实"。
上下文窗口的特点是:即时、高保真、但容量有限且不持久。主流模型的窗口从几万到几十万 token 不等,对于单次任务绰绰有余,但无法承载跨会话的历史积累。把所有记忆都堆进窗口,既不现实,也会稀释注意力,降低推理质量。
上下文窗口是记忆系统的"前台",其他层次的记忆,最终都要以适当的方式注入到这里,才能被 Agent 感知和使用。
2. Skills(技能库)
Skills 是 Agent 的程序性记忆,对应人类"知道怎么做"的那部分认知。
它存储的不是事实,而是经过验证的操作流程、工具调用模式、任务解决策略。例如:如何调用某个 API、处理某类异常的标准步骤、特定领域的分析框架。
Skills 通常以结构化的方式组织,可以是代码片段、函数定义、提示模板或标准操作文档(SOP)。当 Agent 遇到已知类型的任务时,从 Skills 库中检索对应的处理方式,而不是每次从零开始推理。
这一层的核心价值在于可复用性和稳定性。Skills 一旦验证有效,可以反复调用,降低推理成本,也减少因模型随机性带来的行为漂移。
3. JSONL 会话历史(Episodic Memory)
JSONL 会话历史是 Agent 的情景记忆,记录"发生过什么"。
每一条会话记录以结构化的 JSON 格式追加写入文件,包含时间戳、用户输入、Agent 响应、工具调用链、任务状态等字段。JSONL 格式(每行一个 JSON 对象)天然支持流式写入和增量读取,非常适合作为会话归档格式。
{"ts": "2026-04-05T10:23:00Z", "role": "user", "content": "帮我分析上周的需求完成情况"}
{"ts": "2026-04-05T10:23:05Z", "role": "agent", "content": "好的,正在调用数据接口...", "tool_calls": ["get_sales_data"]}
{"ts": "2026-04-05T10:23:08Z", "role": "tool", "name": "get_sales_data", "result": {...}}
会话历史不直接全量注入上下文——那会超出窗口限制。而是通过检索或摘要的方式,将相关片段召回,按需注入。它是记忆系统的"原始档案",保真度最高,也是其他记忆层更新的数据来源。
4. MEMORY.md(语义记忆)
MEMORY.md 是 Agent 的语义记忆,是对长期积累信息的结构化摘要与提炼。
它以 Markdown 格式存储,内容可读性强,便于人工审查和编辑。典型内容包括:
## 用户偏好
- 偏好简洁的回答风格,不喜欢长篇铺垫
- 工作时区:UTC+8,通常在上午处理邮件
## 项目背景
- 当前项目:Agent 记忆系统设计,目标交付日期 2026-Q2
- 技术栈:Python + LangGraph + PostgreSQL
## 已知约束
- 不得调用外部付费 API,优先使用本地模型
MEMORY.md 在每次会话开始时,作为系统提示的一部分注入上下文窗口,为 Agent 提供稳定的"世界观"背景。它的内容由会话历史定期蒸馏生成,更新频率低但信息密度高。
不同层级记忆之间的协作
四种记忆不是孤立存在的,它们构成一个有机的流转系统。
-
会话启动时:MEMORY.md 的内容注入系统提示,为 Agent 提供基础上下文;相关的历史会话片段通过语义检索召回,补充细节;匹配的 Skills 也一并加载。
-
会话进行中:所有交互实时写入 JSONL 历史,上下文窗口动态维护当前对话的完整状态。
-
会话结束后:触发后台异步任务,对本次会话进行摘要分析,更新 MEMORY.md 中的相关条目;如果本次任务产生了可复用的操作模式,则提炼并写入 Skills 库。
阈值触发的上下文压缩
长时间运行的会话会遇到一个现实问题:上下文窗口是有上限的。随着对话轮次增加,窗口逐渐被早期的消息填满,新的信息挤不进来,推理质量也开始下降。解决方案不是简单地截断或丢弃,而是设计一套阈值触发的安全归档流程。具体流程如下:
第一步,监测窗口占用率。 系统持续追踪当前上下文的 token 占用量。当占用率达到预设阈值(例如窗口容量的 70%)时,触发压缩流程,而不是等到窗口溢出再被动处理。留出缓冲空间,是为了确保压缩操作本身还有足够的上下文可以执行。
第二步,识别待归档的消息段。 从窗口中选取最早的一批消息——通常是那些对当前任务直接影响已经减弱的早期轮次——标记为"待归档段"。选取策略可以按时间顺序,也可以结合相关性评分,优先移出与当前任务关联度最低的内容。
第三步,对待归档段做 Summary。 调用模型对这批消息进行摘要,提炼其中的关键信息:涉及的决策、用户表达的偏好、任务状态的变化、重要的工具调用结果等。摘要的颗粒度要足够细,让后续推理不会因为原始细节的缺失而失去关键判断依据。
第四步,将 Summary 更新至 MEMORY.md。 摘要内容按类别合并写入 MEMORY.md 的对应条目,例如新发现的用户偏好追加到偏好区块,任务状态更新覆盖到项目背景区块。MEMORY.md 的每次变更通过版本控制留存,不覆盖历史版本。
第五步,原始消息留档 JSONL,从活跃窗口中移除。 待归档段的原始消息早已实时写入 JSONL 历史,此时只需将其从上下文窗口的活跃区域中安全移除即可。移除不等于删除——原始的每一轮对话、每一次工具调用的完整记录,依然完整保存在 JSONL 文件中,随时可以检索和回溯。
第六步,将 Summary 注入窗口,替代原始内容继续推理。 压缩后,窗口中对应位置由一段简洁的摘要文本替代原来的大段原始对话。Agent 的推理上下文得到释放,同时关键信息通过摘要形式得以保留,会话可以无缝继续。
这一机制的本质是:用信息密度换取窗口空间,但绝不以牺牲原始数据为代价。 窗口中流转的是经过蒸馏的精华,JSONL 中保存的是完整的原始事实,两者分工明确,互为补充。
这种分层协作的设计,使得每一层都专注于自身最擅长的事:窗口负责即时推理,历史负责保真归档,MEMORY.md 负责长期提炼,Skills 负责能力积累。四层之间通过阈值触发、按需召回、定期蒸馏的机制有机连接,构成一个能够随时间持续生长的记忆体系。
记忆的整合回退:不删除,只归档
记忆系统设计中最容易被忽视的一个原则是:不要删除旧记忆,只将其从活跃窗口中移除。 这一原则背后有两个核心考量。
其一,信息价值的不确定性。 某条看似过时的记忆,可能在未来的某个场景中重新变得关键。用户更改了偏好?旧偏好依然是理解用户变化轨迹的重要参照。任务方向调整了?之前的探索路径可能在新阶段重新被采用。删除意味着永久失去这个可能性。
其二,系统必须可审计、可回退。 记忆的更新是一种写操作,写操作必然存在出错的可能——蒸馏逻辑产生了错误摘要、用户信息被错误归类、Skills 收录了一个有缺陷的模式。如果没有回退机制,这些错误会悄无声息地污染整个记忆系统,且难以溯源。
具体的工程实践如下:
JSONL 历史只追加,不修改。 每一条记录一经写入即为不可变。这是天然的审计日志,任何时间点的系统状态都可以通过重放历史来还原。
MEMORY.md 采用版本控制。 使用 Git 管理 MEMORY.md 文件,每次更新产生一个 commit。需要回退时,git revert 或 git checkout 到任意历史版本即可。每个版本的变更记录清晰可查,谁在什么时间因为什么原因更新了哪条记忆,一目了然。
Skills 的变更采用软删除与版本标记。 废弃的 Skill 不直接删除,而是标记为 deprecated,并记录废弃时间和原因。新版本的 Skill 与旧版本共存,支持在需要时显式调用历史版本进行对比验证。
记忆操作本身也写日志。 每次 MEMORY.md 的蒸馏更新、每次 Skills 的新增或废弃,都在独立的操作日志中留档,记录触发条件、操作内容和执行时间。这使得记忆系统的演化过程本身变得透明和可追踪。
Agent 的记忆层,本质上是在一个无状态的推理引擎之上,用工程手段构建出时间连续性的幻觉——但这不是欺骗,而是让 AI 系统真正走向实用的必要设计。上下文窗口、Skills、JSONL 会话历史、MEMORY.md,四种记忆模式各有其位,协同工作。而"只归档、不删除、全程可回退"的操作原则,则是这套系统能够长期可靠运行的基本保障。记忆层不是 Agent 的附加功能,它是 Agent 得以存在于时间之中的方式。
约束与自由,Free Agent!
说到这个,可能有人会想到 LangChain 的演化路径,LangChain 的演化走过了一条清晰的轨迹:强约束的 Chain 管道、完全放开的 AgentExecutor、再到 LangGraph 用图结构重新引入可控边界。这条路上说的"自由",是执行路径的自由——模型能不能自己决定下一步调用哪个工具,看起来就是从约束到自由,再到有限的自由。但 Agent 工程里还有另一个维度的自由,两者不是同一回事。
这里讲的自主度,核心不是人工介入的次数减少了多少,而是 Agent 有没有能力在更长的时间跨度里把一件事稳定做完。能否独立完成一个横跨多个 session、夹杂文件读写与外部服务调用的复杂任务,决定性因素不是模型的推理天花板有多高,而是配套的基础设施是否到位。
出发点也不是直接把控制权交出去,而是先把两件事做扎实:跨 session 的续跑能力和单个 session 内的进度约束机制。这两块没打好地基,Agent 的自主度越高,跑偏的风险反而越大。
长任务的现场保存与恢复
长任务失败的真正原因,大多数时候不是某个步骤执行出错,而是session结束但事情还没做完。
哪怕已经开启了上下文压缩,照样有两类问题绕不过去:其一,试图在单个 session 里把整个应用一口气做完,上下文先到极限,任务被迫中断;其二,只推进了一部分,下一轮 session 启动时找不回现场,要么把做过的事重来一遍,要么错误地认为任务已经结束。这两种失败的根源一致——任务状态没有被写到模型之外的地方。
一个更可靠的组织方式,是把长任务分给两个各司其职的角色:初始化 Agent 与 Coding Agent。这套分工对代码生成、应用搭建、重构迁移一类的场景尤其合适——任务规模超出单个 session 的承载边界,但又能切分成一批有明确验收标准的子任务。
初始化 Agent 仅在整个任务的第一轮出现,它不负责写代码,它负责把任务物化成文件系统里的持久状态:输出结构化的 feature-list.json、可执行的初始化脚本 init.sh、初始 git commit,以及记录当前进度位置的 claude-progress.txt。从这一刻起,任务的全貌和起点就落在磁盘上了,跟模型上下文里还剩多少空间无关。
接下来的多轮 session 交给 Coding Agent 循环处理。每次进来,先读 claude-progress.txt 和 git log,把现场还原,找到当前该推进的功能,实现它,跑测试,把对应条目的 passes 字段翻成 true,提交,退出。下一轮同样的入口,找下一个未完成项,接着来。哪怕中途崩了,重新拉起来也是从文件系统的状态接着走,不存在"从头再来"的情况。
任务状态必须写出来,不能只活在上下文里
上面提示有两个细节值得单拎出来说:进度记在文件里,不记在上下文里;功能清单用 JSON 而不是 Markdown。结构化格式在模型读写时更稳定,不容易在格式层面翻车。判断任务是否完成的依据,是 feature-list.json 里所有条目的 passes 是否全部为 true——是文件说了算,不是模型自己觉得差不多了。
跨 session 的问题解决的是"下次从哪里接",但单个 session 内部照样会出问题。
随着任务拉长,上下文里没有外部进度锚点,Agent 很容易跑偏——在某个子任务上兜圈子,或者任务明明没做完却提前收工。这不是推理能力的问题,而是工作记忆天然不可靠,上下文越长,早期写进去的任务状态就越容易被稀释掉。
解决办法是把任务状态拿出来作为显式的外部控制对象:
{
"tasks": [
{"id": "1", "title": "创建迭代", "desc": "创建新的仓库迭代,以提供的标准格式命名", "status": "completed"},
{"id": "2", "title": "修改页面风格", "desc": "以简约大气的风格,重构当前页面的UI", "status": "in_progress"},
{"id": "3", "title": "代码提交", "desc": "提交至代码仓库", "status": "pending"}
]
}
规则本身并不复杂:任意时刻只允许一个 in_progress,每推进完一步,先把状态文件改掉,再往下走。当前走到哪一步,不靠模型记住,靠文件记录,每轮推理前读取一次。
可以叠加一层轻量干预:当连续多轮都没有更新任务状态时,自动往上下文里塞一条 <reminder>,点出当前进度和剩余项。逻辑不复杂,但它把"Agent 有没有跑偏"从一个只能事后发现的隐患,变成了实时可见、随时可介入的可观测状态。
把前两块地基打好之后,还有一个容易被低估的摩擦点:I/O 的组织方式。文件操作、网络请求、耗时较长的外部命令这类 I/O 操作一旦同步挂在主循环上,整个系统就陷入"模型在干等,token 在消耗,任务没动静"的僵局。可以这么干:把这些操作扔到后台线程去跑,结果通过通知队列在下一次 LLM 调用前注入。主循环不需要操心并发怎么管,只要在每轮开头扫一眼队列有没有新结果,再定下来是继续执行、原地等待还是调整方向。
这个方案的价值不在于技术上有多精妙,而在于它经得起长期维护。如果把整个主循环改造成完整的支持async-await的循环,引入的复杂性往往比它解决的问题还多。后台线程加通知队列,思路直接,出了问题也容易找到根源。对于要长期稳定跑下去的 Agent 系统,可维护性本身就是竞争力的一部分。
Multi-Agent
单个 Agent 有其天然的边界:上下文窗口有限、工具集不能无限扩张、长任务容易跑偏。当任务复杂度超出单个 Agent 的承载能力时,答案不是把这个 Agent 做得更大,而是引入多个 Agent 协同工作——这是 Multi-Agent 的出发点。Multi-Agent 是一个总体范式,描述的是"多个 Agent 参与同一个任务"这件事本身,至于这些 Agent 如何组织、有没有层级、谁来调度,Multi-Agent 并不规定。
常见的组织模式有两种。
一是指挥者模式(同步协作) :主 Agent 充当实时指挥,向 Sub-Agent 下发指令后等待结果返回,再根据结果决定下一步动作。整个执行过程是同步推进的,主 Agent 全程掌控节奏,适合任务之间依赖关系紧密、需要根据中间结果动态调整方向的场景。
二是统筹者模式(异步委派) :主 Agent 在任务开始时完成全局规划,将拆解好的子任务批量委派给多个 Sub-Agent 并行执行,自身退出等待状态,Sub-Agent 完成后将结果写入共享存储,主 Agent 在合适的时机统一收集结果进行整合。适合子任务之间相互独立、可以并行推进的场景。
在实际的工程实现中,统筹者模式通常这样组织:主 Agent 统筹全局,多个独立的 Sub-Agent 并行工作;Agent 之间通过 JSONL 消息队列通信,每条消息格式结构化、边界清晰;.worktrees/ 目录为每个 Sub-Agent 隔离独立的文件操作空间,避免并行写入互相污染;.tasks/ 目录维护任务图,记录子任务的依赖关系和当前状态,主 Agent 通过任务图而非上下文感知整体进度。
隔离与协作:先有协议,再谈并行
Sub-Agent 适合承接的,是边界清晰、可独立验收的子任务。 代码模块的实现、特定文件的分析、单一服务的调试——这类任务输入输出明确,执行过程不需要主 Agent 实时介入。Sub-Agent 的操作过程、中间状态、调试细节,全部留在自己的独立上下文里,不往主 Agent 的上下文里渗透。主 Agent 只关注结果:任务完成了吗?结果是什么?有没有需要上报的异常。过程细节对主 Agent 是不透明的,也应该是不透明的。
协作方式必须以协议的形式固定下来。 模型记不住谁在负责什么、依赖谁的结果、什么时候可以开始下一步。一旦任务之间产生依赖,这些关系就必须从"模型应该知道"变成"协议明确写明"。协议的形式可以是这样:
{
"task_id": "task-003",
"assigned_to": "agent-backend",
"depends_on": ["task-001", "task-002"],
"input": {
"schema_file": ".tasks/outputs/task-001/schema.json",
"api_spec": ".tasks/outputs/task-002/api_spec.json"
},
"output_path": ".tasks/outputs/task-003/",
"success_criteria": "所有接口通过单元测试",
"return_format": "summary_only"
}
任务 ID、执行者、依赖项、输入来源、输出路径、验收标准、返回格式——这些都是协议字段,不是自然语言描述,不依赖任何 Agent 去"理解"或"记住"。
启动顺序不能反。 协议先定,隔离先做,再谈协作和并行。具体来说:先用 .tasks/ 把任务图和依赖关系写清楚,再用 .worktrees/ 为每个 Sub-Agent 划定文件操作边界,最后才是主 Agent 通过 JSONL 消息队列分派任务、Sub-Agent 执行后只回复摘要。顺序颠倒,隔离没做好就开始并行,出了问题既不知道是谁改的,也不知道改了什么。
幻觉在多 Agent 之间会被放大
单个 Agent 产生幻觉,影响是局部的。多个 Agent 频繁交互时,幻觉会被逐层放大:Agent A 输出了一个带偏差的结论,Agent B 将其作为可信输入继续推理并进一步强化,Agent C 在此基础上叠加,最终所有 Agent 收敛到同一个高置信度的错误结论。每个 Agent 单独看都"言之凿凿",但整个系统已经跑偏了。
应对这个问题,需要引入独立的交叉验证机制。最直接的方式是设置一个游离于主执行链之外的验证 Agent,它不参与任务执行,只负责对其他 Agent 的关键输出进行独立审查——用不同的推理路径、不同的工具、甚至不同的模型,得出独立判断,再与主链结论对照。除此之外也可以采用多路并行再投票的方式:对同一个关键子任务,派发给两个独立的 Sub-Agent 分别执行,结果一致才采信,不一致则上报主 Agent 裁决。
核心原则是:关键结论不能只有一个信源,系统内部要有能独立说"不"的声音。
控制 Sub-Agent 的数量与深度
Multi-Agent 系统很容易在"再加一个 Agent 来处理这个问题"的路上越走越远,最终变成一张没人看得清楚的调用网。
Sub-Agent 的数量和调用深度都需要硬性约束。调用层级超过三层,整个系统的可观测性就会急剧下降——主 Agent 不知道第三层的 Agent 在做什么,出了问题也很难溯源。并行 Sub-Agent 的数量同样要有上限,不是越多越快,协调开销和状态同步的复杂度会随数量非线性增长。
系统提示也要最小化。给每个 Sub-Agent 的系统提示,只包含它完成当前任务所必需的信息:角色定义、工具权限、输出格式要求、返回规范。背景知识、全局目标、其他 Agent 的情况——一概不给。系统提示越长,Agent 的注意力就越分散,执行的稳定性就越差。给得少,反而做得准。
如何评测一个Agent
评测的本质与核心要素
要评测一个 Agent 的好坏,本质上需要三样东西:测试用例、评测标准、自动验证机制。
但在这三者之间,有一个容易被忽视的陷阱——分数高,效果就一定好吗? 答案肯定是不一定。评测本身是有盲区的。测试用例的覆盖范围、评分器的设计质量,都会直接影响最终的分数是否"可信"。一个在测试集上表现优秀的 Agent,放到真实业务场景里可能一塌糊涂。所以评测的首要目标,不是追求高分,而是让这个分数有意义、可信赖。
传统 NLP 评测相对可控:给一个输入,期望一个输出,差距可量化。但 Agent 的评测面临两个根本性挑战:
第一,输入空间几乎是无限的。 Agent 面对的是开放世界——用户的意图千变万化,工具调用的组合方式也是指数级的。你不可能用有限的测试用例覆盖所有场景。
第二,同一任务多次执行,结果也会有差异。 由于大模型的随机性(temperature > 0),加上工具调用链路的复杂性,同一个 Agent 对同一个问题,第一次可能答对,第二次就答错了。这种"不稳定性"是评测中必须正视的问题。
两个关键指标:pass@k 与 pass^k
正是为了应对上述不稳定性,Agent 评测引入了两个互补的指标:
pass@k —— 探索能力上限
对同一任务运行 k 次,只要有 至少一次 答对,就算通过。
这个指标衡量的是 Agent 的能力天花板:它在理论上能不能解决这类问题。如果 pass@k 很低,说明 Agent 根本不具备处理该任务的能力,改模型、改架构是当务之急。
pass^k —— 基础质量保证
同样运行 k 次,要求 每一次 都答对,才算通过。
这个指标衡量的是 Agent 的稳定性和可靠性:它能不能持续稳定地完成任务。在生产环境中,这个指标往往比 pass@k 更重要——用户不会给你 10 次机会。
三类评分器:如何判断答案对不对?
知道了怎么跑,还要知道怎么"判"。Agent 的输出形式多样,不同类型的输出适合不同的评分方式。
1. 代码评分器 —— 有明确答案时首选
适用场景: 输出有确定性答案的任务。比如数学计算、SQL 查询结果、代码运行输出、结构化数据提取等。
做法: 写脚本进行精确匹配或规则校验,比如比对数值是否相等、JSON 结构是否正确、代码执行结果是否符合预期。
确定性: 最高。结果非黑即白,没有歧义,可完全自动化,速度快,成本低。
2. 模型评分器(LLM-as-Judge)—— 评价语义质量
适用场景: 输出需要主观判断的任务。比如文本摘要质量、回答是否满足用户意图、对话是否连贯等。
做法: 用一个强大的 LLM作为裁判,给它设计清晰的评分 Prompt,让它判断 Agent 的输出是否满足要求,并给出评分或理由。
确定性: 中等。模型评分器本身也有随机性,同样可能判断不稳定,需要多次采样取均值,或设计一致性校验。
注意: Prompt 的设计质量直接决定评分质量,要避免让裁判模型"放水"或"偏心"。
3. 人工评分器 —— 兜底的最终裁判
适用场景: 代码评分器和模型评分器都拿不准的情况。比如输出涉及价值判断、专业领域知识、或者评分结果存在分歧时。
做法: 由人工专家对输出进行标注和评分,必要时引入多位标注者取众数,计算标注一致性(Cohen's Kappa)以保证评分可靠。
确定性: 最低,但质量最高。成本高、速度慢,适合用于校准其他评分器,或处理高价值的关键案例。
三类评分器的层级使用策略
实践中,这三类评分器不是非此即彼,而是按层级叠加使用:代码评分器(优先)→ 模型评分器(次之)→ 人工评分器(兜底)
能用代码判的,绝不用模型判;模型判不了的,才交给人工。这样既保证了效率,又保证了准确性。
如何搭建一个可靠的评测环境?
理论说完了,落地时还有几个关键工程细节:
用例选择要有共识
测试用例必须选择人与人之间没有异议的,标准清晰、预期确定。模糊的、主观性强的用例,评测结果本身就不可信,不要放进核心测试集。
同时,测试用例要同时包含正例和反例。正例验证 Agent 该做的事做到了,反例验证 Agent 不该做的事没有做——两者缺一不可。
测试环境要隔离
不同轮次的测评之间,必须保证环境隔离。上一轮的执行结果、缓存、状态,不能污染下一轮。否则你看到的"稳定性提升",可能只是因为命中了缓存。
结果不好时,先别急着改 Agent
这是一个反直觉但非常重要的原则:
当评测结果很差时,第一步不是去改 Agent,而是先审查测试用例本身有没有问题。
实际工作中,很多"Agent 表现差"的根源,是测试用例写错了、预期答案有歧义、或者评分器设计有 bug。如果没搞清楚这一点就急着调整 Agent,最终可能越改越偏。
正确的顺序是:
- 审查测试用例 —— 预期答案是否正确?用例覆盖是否合理?
- 审查评分器 —— 评分逻辑是否准确?是否存在系统性偏差?
- 确认是 Agent 问题后 —— 再针对性地优化 Agent。
如何跟踪Agent的执行过程
为什么 Agent 需要 Trace 体系
做过后端开发的人都知道,每一个 API 请求都会携带一个 trace_id。当线上出现问题时,拿着这个 ID 就能把整条请求链路完整地还原出来——哪个服务出了问题,哪一步耗时异常,一目了然。
Agent 同样需要这样一套机制,而且需求更加迫切。
原因在于:Agent 的错误和传统代码的错误有本质区别。 传统代码的 bug 通常是确定性的——同样的输入,必然触发同样的错误,复现容易。但 Agent 的错误,更多发生在某一轮的决策层面:它选错了工具、误解了用户意图、在多步推理的第三步走偏了方向。这类错误往往难以复现,事后也很难说清楚"它当时到底在想什么"。
没有完整的记录,就没办法稳定地复现失败案例;没办法复现,就没办法定向修复。Trace 体系,是 Agent 工程化的基础设施,不是可选项。
Trace 里应该记录什么?
每一次 Agent 的完整运行,都应该留下一份详尽的"执行档案"。具体来说,至少需要包含以下内容:
完整的 Prompt 内容
不只是用户输入的那句话,而是实际送进模型的完整 Prompt——包括 System Prompt、动态注入的技能描述(Skills)、上下文背景信息等。很多时候问题就出在这里:注入的内容有误,或者拼接逻辑出了问题,但如果只记录"用户说了什么",根本看不出来。
完整的对话历史
用户与 Agent 之间每一轮的消息记录,按时序完整保存。多轮对话中,早期轮次的信息往往会影响后续决策,缺了任何一轮都可能导致溯源断链。
每一次工具调用的入参和返回值
Agent 调用工具时传入了什么参数,工具返回了什么结果,都要完整记录。工具调用是 Agent 与外部世界交互的关键节点,很多错误的根源就在这里——要么参数拼错了,要么工具返回了异常值但 Agent 没有正确处理。
最终输出
Agent 最终给用户的回复内容,原文保存,不做任何裁剪。
Token 消耗
记录每一轮的 token 用量,以及整次运行的总消耗。这不只是成本问题——Token 消耗异常飙高,往往是 Agent 陷入死循环或反复重试的信号。
<think> 内容(支持深度思考的模型)
对于支持深度思考(如 DeepSeek-R1、QwQ 等)的模型,模型在 <think> 标签中输出的推理过程也应该完整记录。这是理解模型"为什么这么决策"的最直接窗口,排查逻辑错误时价值极高。
如何建立 Trace,而不破坏主循环?
这是工程实现上最关键的一个原则:建立 Trace 体系时,不要动主循环的代码。 正确的做法是:用事件流的方式进行记录。
Agent 主循环在执行过程中,只需要在关键节点发出事件(emit event),比如on_call_model、on_tool_call_finish、on_output_end等。Trace 系统作为独立的消费者,订阅这些事件,异步地完成记录和存储。
这样的好处显而易见:主循环代码保持干净,Trace 逻辑完全解耦,两者可以独立迭代,互不干扰。即使 Trace 系统出了问题,Agent 本身仍然可以正常运行。
常见问题
看了上面这么多,大致能总结一下常见的问题以及如何调整了吧
| 问题 | 描述 | 修复方式 |
|---|---|---|
| 把系统提示当知识库用 | 内容越堆越多,真正重要的规则反而被淹没 | 系统提示只放约定和规则,领域知识统一移入 Skills 管理 |
| 工具数量野蛮增长 | 工具太多太杂,Agent 频繁拿错工具用 | 合并功能重叠的工具,用清晰的命名空间做好区分 |
| 缺乏结果验证 | Agent 声称任务完成,但无从核实真假 | 为每类任务预先定义可执行的验收标准,说完成就能验 |
| 多 Agent 协作无边界 | 状态互相污染,出了问题不知道该怪谁 | 明确每个 Agent 的角色与权限,用 worktree 隔离,配置 maxTurns 上限 |
| 记忆从不整合 | 对话到第 20 轮后,上下文爆满,决策质量明显下滑 | 实时监控 token 占用,超过阈值自动触发记忆压缩与整合 |
| 没有回归评测 | 改了某个地方,不知道有没有悄悄搞坏别的地方 | 每个真实失败案例,立刻转化为测试用例,纳入回归集 |
| 过早引入多 Agent | 协调和通信的开销反而超过了并行带来的收益 | 先画任务依赖图,跑通单 Agent 的能力上限,再决定是否拆分 |
| 约束靠自觉不靠机制 | 规则写在文档里,Agent 执行时选择性遵守 | 把期望行为落地为工具校验、Linter 或 Hook,用机制代替信任 |
随便说说
我很认同曾经看到的一段描述:
从程序员写下的代码,到机器最终执行的机器码,中间经历了这样一条链路:
程序员 → 高级程言 → 汇编语言 → 机器码
AI 的出现,在这条链路的最前端插入了新的一层:
程序员 → 自然语言 → AI → 高级程序语言 → 汇编语言 → 机器码
这个变化看似降低了门槛,但不必过于担心"人人都能编程"会让技术人员失去价值。
原因在于:这里的自然语言并不等于日常聊天的语言。 想要用自然语言准确、高效地表达一个复杂的技术意图,本身就需要对这个领域有足够深的理解——你得知道该用什么术语、该约束哪些边界、该避免哪些歧义。简单任务的门槛确实低了,但复杂任务对表达者的要求依然存在,只是换了一种形式。
沿着这个思路往前想,还有一个有趣的问题:
现在的编译器会做语法检查——代码写错了,直接报错。
未来会不会出现意图检查器?
Prompt 表达模糊,直接警告;意图有歧义,强制要求澄清——用机制倒逼人写出一个真正清晰的 Prompt。
如果真有这一天,"写好 Prompt"就不只是一种能力,而会变成一种必须遵守的工程规范。
总结!
好了,让我们总结一下吧,这么长,别看了后面忘掉前面了:
我们从 Agent 的基本概念出发,一路走到了工程实践的各个关键环节。
Agent 的本质,是在 LLM 的推理能力之上,通过工具扩展了行动边界,形成"感知 → 决策 → 行动 → 反馈"的闭合循环。Agent Loop 是整个系统的骨架,它本身应该保持极简和稳定,所有扩展能力都加在循环的外面,而不是改动循环本身。
工具设计决定了 Agent 能做什么,ACI 的核心思路是以 Agent 为中心而非以系统为中心来设计工具——命名语义直观、粒度与目标动作对齐、错误反馈结构化、职责边界清晰、操作尽量幂等。工具设计的质量,直接决定了 Agent 行为的稳定性上限。
Harness 是套在 LLM 外面的执行环境,它不提供智能,它让智能能够稳定运行。约束 Agent 行为的最可靠方式,不是在提示词里声明"不要做什么",而是用 Linter、Hook、代码校验等机制让错误的行为从根本上无法发生。
上下文工程是保证 Agent 稳定的第一要素。随着上下文增长,关键信息得到的注意力会被噪声稀释,这是 Context Rot 的根源。应对方式是分层设计上下文——常驻层保持精简稳定,领域知识通过 Skills 按需加载,运行时信息动态注入,跨会话状态落到 MEMORY.md。Prompt Caching 的收益也来源于此:稳定的前缀命中率越高,边际成本越低。
Skills 是上下文工程里最有价值的模式之一。系统提示只保留索引,完整的操作流程、约束和示例放在独立文件里,触发时才注入。触发这件事必须由工程侧每轮强制扫描保证,不能依赖主模型自己记住。
记忆系统是让 Agent 真正具备时间连续性的基础设施。上下文窗口、Skills、JSONL 会话历史、MEMORY.md,四层各司其职,通过阈值触发、按需召回、定期蒸馏有机协作。旧记忆永远只归档不删除,MEMORY.md 用版本控制管理——可回退是记忆系统长期可靠运行的基本保障。
长任务与自主度的核心不是减少人工介入次数,而是把任务状态写到模型之外。进度记在文件里、任务图记在 .tasks/ 里、每个 Sub-Agent 的操作空间用 .worktrees/ 隔离——状态的持久化是跨 session 续跑的前提,有了这个地基,自主度才有意义。
Multi-Agent 是单 Agent 承载能力到达边界后的自然延伸,不是一开始就应该引入的架构。协议先于并行,隔离先于协作。Sub-Agent 只承接边界清晰、可独立验收的子任务,系统提示最小化,调用层级不超过三层。幻觉在多 Agent 之间会被逐层放大,关键结论需要独立的交叉验证,系统内部要有能独立说"不"的声音。
评测的核心是让分数有意义,而不是追求高分。pass@k 衡量能力上限,pass^k 衡量稳定性,两者都需要多次重跑才有意义。评分器按层级使用:代码评分器优先,模型评分器次之,人工评分器兜底。结果不好时,第一步永远是审查测试用例和评分器,而不是急着改 Agent。
Trace 体系是 Agent 工程化的基础设施。完整的 Prompt、对话历史、工具调用入参和返回值、Token 消耗、<think> 内容——每一次运行都应该留下完整的执行档案。建立 Trace 的方式是事件流,不动主循环,Trace 系统作为独立消费者异步订阅和记录。
把这些拼在一起,Agent 工程的核心逻辑其实可以归结为一句话:
模型负责推理,工程负责约束;上下文决定质量,记忆决定连续性;先把单 Agent 做扎实,再考虑多 Agent 协作;能跑不够,稳跑才算数。
Agent 技术还在快速演进,但这些基本原则的有效性不会随模型迭代而消失——越是模型能力在提升,工程基础设施的价值就越能被充分释放。希望这篇文章能给你一个清晰的全局视角,在实际动手构建 Agent 系统时少走一些弯路。