做 Agent SDK 必须支持的插件能力:8 个钩子搞定横切关注点
这是 code-artisan 拆解系列第四篇。
前言
上一篇我们把 4 个 builtin 工具补齐了,Agent 已经能读文件、改文件、跑命令、起后台进程。看起来这套东西已经能干活,但如果我们把它放到"长会话 + 复杂任务"的视角里推演一下,就会出现下面一些问题:
- 会话每多走一步,messages 数组就多一对 tool_use / tool_result。token 数一路涨,迟早撞到模型 context 上限。主循环自己没有压缩的机制。
- 假设 LLM 一上来就魔怔,连续 5 次拿同样入参调
read_file,主循环也不会识别,只会乖乖把工具跑五遍,等到 maxSteps 兜底才停。这中间烧的 token 和等待时间都是干浪费。 - 我们想给所有工具加一层耗时统计、加日志、加额度校验,目前唯一的口子是去每个工具的
invoke里手动改。一个工具一次还好,14 个 builtin 都改一遍就是体力活。
这些问题有个共同点:它们都不属于"调 LLM → 执行 tool → 再调 LLM"这个核心循环的职责 。硬塞进主循环只会让 agent.ts 变成一个又大又长的杂烩文件,加一个能力就要碰一次主循环。这不是我们做完前三篇还想继续往前走的状态。
这一篇我们要把这些横切关注点从主循环里搬出去,落到一个插件系统 上。这种东西在后端框架圈里更常被叫做 middleware(Express、Koa、Hono 都用这个词),下面写代码的时候我们直接沿用 middleware 这个名字,跟标题里"插件"指的是同一件事。code-artisan 的真身用了 8 个生命周期钩子(beforeAgentRun / afterModel / beforeToolUse 这些),这篇文章里我们只用最少必要的 4 个就够把机制讲清楚。
顺序还是跟前三篇一样:每个 Part 一份独立可跑的代码,链接挂在节末。
Part 1:middleware 接口 + 主循环织入
我们先把上一篇结尾的主循环捡回来。骨架长这样:
typescript
async run({ signal }: { signal?: AbortSignal } = {}) {
while (!signal?.aborted) {
const response = await this.provider.invoke({
messages: this.messages,
tools: this.tools,
signal,
});
this.messages.push(response);
const toolUses = response.content.filter(c => c.type === "tool_use");
if (toolUses.length === 0) return;
const toolMsgs = await Promise.all(
toolUses.map(async (call) => {
try {
const result = await this.toolImpls[call.name](call.input);
return { role: "tool", tool_use_id: call.id, content: result };
} catch (e: any) {
return { role: "tool", tool_use_id: call.id, content: `Error: ${e.message}` };
}
})
);
this.messages.push({ role: "tool", content: toolMsgs });
}
}
这个循环里其实有 4 个时间点天然适合伸出去做事:
- 调 LLM 之前 (
invoke调用前),我们可以在这里改即将传给 LLM 的messages和tools - 调 LLM 之后(拿到 response 之后),我们可以观察输出、改 response、决定要不要继续
- 每个工具调用之前(每个 tool_use 真正 invoke 前),我们可以拦截、改 input、做权限/额度校验
- 每个工具调用之后(拿到 toolResult 之后),我们可以记日志、加 telemetry、统计耗时
我们就用这 4 个钩子位来定义 middleware 的形态:
typescript
interface AgentContext {
// 共享的 mutable 上下文:middleware 想改 messages / tools / shouldStop 直接改字段
// 不走 "return 一个 patch 再让框架合并" 的弯路
messages: Message[];
tools: FunctionTool[];
shouldStop?: boolean;
}
interface AgentMiddleware {
// 4 个钩子全是 async:middleware 完全可能要 await(Part 4 的 auto-compact 会调 LLM)
beforeModel?: (ctx: AgentContext) => void | Promise<void>;
afterModel?: (ctx: AgentContext, message: AssistantMessage) => void | Promise<void>;
beforeToolUse?: (ctx: AgentContext, toolUse: ToolUseContent) => void | Promise<void>;
afterToolUse?: (ctx: AgentContext, toolUse: ToolUseContent, toolResult: string) => void | Promise<void>;
}
middleware 的写法约定 :每个 middleware 都是一个工厂函数返回对象,而不是 class。后面 Part 2 / Part 3 / Part 4 的 middleware 都长这样:一个 xxxMiddleware(options) 函数,里面 return { beforeModel, afterModel, ... }。这么写是因为 middleware 经常要自己持有一些内部状态(hash 滑动窗口、计数器、配置参数),用闭包就够了,比 class 轻一点,也避开 this 绑定的小坑。
接下来我们改主循环。改动其实就一句话:在 4 个钩子位上挨个 await 所有 middleware 的对应方法。
typescript
class Agent {
private middlewares: AgentMiddleware[] = [];
use(mw: AgentMiddleware) {
this.middlewares.push(mw);
return this;
}
async run({ signal }: { signal?: AbortSignal } = {}) {
const ctx: AgentContext = {
messages: this.messages,
tools: this.tools,
};
// !ctx.shouldStop 是给 middleware 留的干净退出开关:标 true 后主循环跑完当前 step 就退
while (!signal?.aborted && !ctx.shouldStop) {
for (const mw of this.middlewares) await mw.beforeModel?.(ctx);
const response = await this.provider.invoke({
messages: ctx.messages,
tools: ctx.tools,
signal,
});
ctx.messages.push(response);
for (const mw of this.middlewares) await mw.afterModel?.(ctx, response);
const toolUses = response.content.filter(c => c.type === "tool_use");
if (toolUses.length === 0) return;
const toolMsgs = await Promise.all(
toolUses.map(async (call) => {
for (const mw of this.middlewares) await mw.beforeToolUse?.(ctx, call);
try {
const result = await this.toolImpls[call.name](call.input);
for (const mw of this.middlewares) await mw.afterToolUse?.(ctx, call, result);
return { role: "tool", tool_use_id: call.id, content: result };
} catch (e: any) {
return { role: "tool", tool_use_id: call.id, content: `Error: ${e.message}` };
}
})
);
ctx.messages.push({ role: "tool", content: toolMsgs });
}
}
}
对比一下上一篇的版本,主循环只多了 4 处 for-await ,干的事情还是同一件:调 LLM、执行工具、把结果塞回去。剩下的横切逻辑全部推到 middleware 里去,这正是我们想要的状态。!ctx.shouldStop 这个干净退出开关,Part 2 的 loop-detection 检测到死循环时就会用到。
跑个最简 demo:timing middleware
为了证明这套机制真能跑,我们先写一个最简单的 middleware:统计每次 LLM 调用和每次工具调用的耗时。这个 middleware 本身没什么生产价值,但它能把 4 个钩子全都用到,足够把机制亮相清楚。
typescript
function timingMiddleware(): AgentMiddleware {
const toolTimers = new Map<string, number>();
let modelStart = 0;
return {
beforeModel: () => {
modelStart = Date.now();
},
afterModel: () => {
console.log(`[timing] model call took ${Date.now() - modelStart}ms`);
},
beforeToolUse: (_ctx, call) => {
toolTimers.set(call.id, Date.now());
},
afterToolUse: (_ctx, call) => {
const elapsed = Date.now() - (toolTimers.get(call.id) ?? 0);
console.log(`[timing] tool ${call.name} took ${elapsed}ms`);
toolTimers.delete(call.id);
},
};
}
注入 agent 也很轻:
ini
const agent = new Agent({ provider, tools, toolImpls });
agent.use(timingMiddleware());
await agent.run({ signal });
实际跑下来,输出大概长这样:
csharp
[timing] model call took 832ms
[timing] tool read_file took 14ms
[timing] model call took 651ms
[timing] tool str_replace took 7ms
[timing] model call took 489ms
我们没动主循环一行,就把"耗时统计"这件横切的事干净地外挂了进去。同样的接口可以再挂日志、挂权限、挂额度校验,思路完全一样。剩下三个 Part 我们就用这套接口,挨个写几个有实际用处的 middleware。
🟢 在线试一下(看 timing middleware 在 ReAct loop 里的实际输出)→ Part 1 Playground
Part 2:loop-detection middleware · 让 Agent 自己识别死循环
机制有了,第一个有实际用处的 middleware 我们写死循环检测。
先把场景说清楚:LLM 在一个 ReAct loop 里,理论上每一步都应该往前推进,但实际跑下来你会发现,模型偶尔会卡住。最常见的几种姿势:
- 调
read_file({ path: "src/utils.ts" })拿到一个错(路径不对),它没意识到,下一步又调同一个路径。 - 调
bash({ command: "npm test" }),命令报错,它分析了一下错误信息,下一步又跑同一个npm test。 - 调
grep找一个不存在的符号,找不到,它换了个"差不多但其实没区别"的关键词又找了一遍。
如果不管它,主循环会乖乖把同一个工具陪它跑五遍、十遍、直到 maxSteps 兜底。这中间烧掉的 token 都是干浪费,更糟的是用户得等 30 秒才知道"它没救了"。我们需要一个机制:同一个工具调用模式在最近的步骤里反复出现时,主动叫停。
思路:把每次工具调用 hash 一下,扔进一个滑动窗口
我们要回答的问题是"近期是不是反复在调同一个工具 + 同一个入参"。一个直接的做法是把每次 tool_use 的 (name, input) 拼成一个字符串、哈希一下、扔进一个固定大小的滑动窗口。然后看窗口里某个 hash 出现的次数。
为什么用 hash 不用直接比对 input 对象?两个原因:
- 比较成本低。窗口里堆着 N 个 input 对象,每次新来一个要跟 N 个比一遍,深比较挺重的。hash 后比的是定长字符串,O(N) 但每次 O(1)。
- 键容易复用。hash 是一个稳定的 string,可以直接做 Map 的 key 来计数。
阈值方面我们用两档:
- warn 阈值(比如 3 次):往 messages 里塞一条 system 提醒,告诉 LLM "你好像在重复,换个思路"。这一档不停 agent,给 LLM 一次自我纠正的机会。
- hard 阈值 (比如 5 次):直接把
ctx.shouldStop = true,主循环跑完当前 step 就干净退出。同时也塞一条 system 消息告诉 LLM 为什么停的,让它最后能给用户一个合理的总结。
完整代码
ini
import { createHash } from "node:crypto";
interface LoopDetectionOptions {
windowSize?: number;
warnThreshold?: number;
hardLimit?: number;
}
const WARN_MESSAGE =
"SYSTEM: 检测到你似乎在重复调用同一个工具。请确认当前策略是否有效,必要时换个思路。";
const STOP_MESSAGE =
"SYSTEM: 重复调用模式已触发硬限制。请直接告诉用户当前进展和遇到的障碍,不要再继续尝试。";
function loopDetectionMiddleware(options: LoopDetectionOptions = {}): AgentMiddleware {
const windowSize = options.windowSize ?? 20;
const warnThreshold = options.warnThreshold ?? 3;
const hardLimit = options.hardLimit ?? 5;
// 状态全在闭包里,每个 .use(loopDetectionMiddleware()) 都拿到独立实例,多 agent 并发互不串台
const hashes: string[] = [];
let warned = false;
return {
// 钩子用 afterModel:LLM 已经决定调哪些工具,但还没真跑,是阻止资源浪费的最早时机
afterModel: ({ messages, ...ctx }, message) => {
const toolUses = message.content.filter(c => c.type === "tool_use");
if (toolUses.length === 0) return;
for (const tu of toolUses) {
const h = createHash("md5")
.update(`${tu.name}:${JSON.stringify(tu.input)}`)
.digest("hex")
.slice(0, 12);
hashes.push(h);
}
if (hashes.length > windowSize) {
hashes.splice(0, hashes.length - windowSize);
}
const counts = new Map<string, number>();
let max = 0;
for (const h of hashes) {
const next = (counts.get(h) ?? 0) + 1;
counts.set(h, next);
if (next > max) max = next;
}
if (max >= hardLimit) {
(ctx as any).shouldStop = true;
// 本轮 LLM 不会看到这条消息(主循环已退);留给下一次 agent.run() 用,见下面解释
messages.push({ role: "user", content: [{ type: "text", text: STOP_MESSAGE }] });
console.log(`[loop-detection] hard stop triggered (max repeat = ${max})`);
} else if (max >= warnThreshold && !warned) {
warned = true; // 警告只发一次,重复消息只会让 LLM 更乱
messages.push({ role: "user", content: [{ type: "text", text: WARN_MESSAGE }] });
console.log(`[loop-detection] warning injected (max repeat = ${max})`);
}
},
};
}
硬停时有一处反直觉的细节:我们已经把 shouldStop = true 了,但代码里还把 STOP_MESSAGE 塞进了 messages,这步看着像多余。
它不多余,是给"下一次"用的。如果调用方在硬停之后继续会话(比如用户看到失败提示后说"再试一次"),下一轮 agent.run() 进来时,LLM 会从 messages 里读到这条 STOP 消息,知道"上一轮是被硬停的,原因是重复调用",从而给出一个合理的总结,而不是接着上一轮的失败模式继续转圈。把这条消息留在 messages 里,本质上是给会话留了一份"上一轮发生了什么"的记账。
触发场景演示
把 loop-detection 挂到 agent 上:
ini
const agent = new Agent({ provider, tools, toolImpls });
agent.use(timingMiddleware());
agent.use(loopDetectionMiddleware({ warnThreshold: 3, hardLimit: 5 }));
await agent.run({ signal });
我们构造一个故意会让 LLM 卡死循环的场景:要求它读一个不存在的文件并分析内容。LLM 会反复调 read_file 同样的路径。输出大致是这样:
scss
[timing] model call took 712ms
[timing] tool read_file took 3ms // 第 1 次:报错"文件不存在"
[timing] model call took 645ms
[timing] tool read_file took 2ms // 第 2 次:又调同样的
[timing] model call took 598ms
[timing] tool read_file took 2ms // 第 3 次
[loop-detection] warning injected (max repeat = 3)
[timing] model call took 723ms // LLM 看到 warning 了,但没改策略
[timing] tool read_file took 2ms // 第 4 次
[timing] model call took 654ms
[timing] tool read_file took 2ms // 第 5 次
[loop-detection] hard stop triggered (max repeat = 5)
[timing] model call took 489ms // 当前 step 跑完,下一轮被 shouldStop 拦掉
5 次硬停在这个例子里省下大约 20 秒的额外等待,每多一次无意义的模型调用还会多烧几百到几千个 token。一旦 SDK 真的有用户用起来,这种死循环检测就是省时间也省钱的事。
🟢 在线试一下(看 loop-detection 在死循环场景下的拦截过程)→ Part 2 Playground
Part 3:micro-compact middleware · 把旧 tool_result 压成占位符
第二个 middleware 我们来碰前言里提到的第一个问题:长会话 token 一路涨,迟早撞 context 上限。
先看看 token 是怎么堆起来的。一个典型的 coding 会话,假设跑了 20 步,每一步 LLM 都调一两个工具。read_file 的返回随便就是几千字符,bash 跑个 npm test 也能吐一两千字符,grep 一搜结果上百行。这些 tool_result 全都老老实实留在 messages 数组里。下一次调 LLM,我们就把这堆历史完整地喂回去。
问题是:LLM 真的需要看全这些历史 tool_result 吗?
绝大多数时候不需要。回想一下我们自己在 Cursor / Claude Code 里干活的时候:早期那些 read_file 的输出,到了任务后段往往只剩"我之前确实看过这个文件"这一层信息,具体内容已经不重要了,因为关键的内容 LLM 多半已经在思考里"消化"过、或者改进过文件。真正需要逐字保留的是最近几次工具调用的结果。
所以最轻量的压缩思路就出来了:保留最近 N 个 tool_result 原文,把更早的内容替换成一行占位符。
一份占位符的设计
我们要替换成什么样的占位符?两个要求:
- 保留 tool_use / tool_result 配对结构 。Anthropic / OpenAI 协议都要求 tool_use 必须有对应的 tool_result。我们不能直接删掉这条 tool 消息,否则下一次调 LLM 就 400 了。压缩只能改
content字段,结构不动。 - 占位符里要带工具名 。让 LLM 一眼看到"哦我之前用
read_file看过某个东西",而不是看到一个孤零零的[Previous output omitted]完全没上下文。
这两条加起来,我们的占位符就长这样:
csharp
[Previous tool call output omitted: used read_file]
短、明确、带工具名。
完整代码
typescript
interface MicroCompactOptions {
keepRecent?: number;
}
function microCompactMiddleware(options: MicroCompactOptions = {}): AgentMiddleware {
const keepRecent = options.keepRecent ?? 10;
return {
// 钩子用 beforeModel:哪怕中间没触发任何工具,下次调 LLM 也会重新走一遍压缩判断
beforeModel: ({ messages }) => {
const toolNameById = buildToolNameMap(messages);
// 把所有 tool_result 按出现顺序铺平到一个数组,方便算"哪些要 stub"
const allResults: ToolResultContent[] = [];
for (const msg of messages) {
if (msg.role !== "tool") continue;
for (const c of msg.content) {
if (c.type === "tool_result") allResults.push(c);
}
}
if (allResults.length <= keepRecent) return;
// 倒数 keepRecent 个保持原文,更早的挨个换 content(不改 messages 结构)
const stubCount = allResults.length - keepRecent;
for (let i = 0; i < stubCount; i++) {
const r = allResults[i];
// 已经 stub 过的不再 stub,避免占位符嵌套或边界情况
if (r.content.startsWith("[Previous tool call output omitted")) continue;
const toolName = toolNameById.get(r.tool_use_id) ?? "tool";
r.content = `[Previous tool call output omitted: used ${toolName}]`;
}
},
};
}
// tool_name 只存在 assistant 消息的 tool_use 块里;tool_result 只有 tool_use_id,先扫一遍建索引
function buildToolNameMap(messages: Message[]): Map<string, string> {
const map = new Map<string, string>();
for (const msg of messages) {
if (msg.role !== "assistant") continue;
for (const c of msg.content) {
if (c.type === "tool_use") map.set(c.id, c.name);
}
}
return map;
}
这里有一处设计选择想单独强调:我们是在原对象上直接改 r.content,没有构造新的 messages 数组。
这不是为了少写两行代码。主循环和其他 middleware 跑的时候,拿到的都是同一个 messages 引用。如果我们整个换数组,下游引用就指向旧数组了,压缩白做。原地改最稳。这种"middleware 共享 mutable 上下文"的约定是 Part 1 设计 AgentContext 时就定下的基调,到这里得到了实际的兑现。
实际效果
把 micro-compact 挂上:
ini
const agent = new Agent({ provider, tools, toolImpls });
agent.use(timingMiddleware());
agent.use(loopDetectionMiddleware());
agent.use(microCompactMiddleware({ keepRecent: 5 }));
await agent.run({ signal });
跑一个会持续调 10 几次工具的任务(比如让它"读 src 下所有 .ts 文件,统计每个文件的导出数量"),跑到第 8 步时,messages 数组里的早期 tool_result 已经长这样:
less
{
role: "tool",
content: [{
type: "tool_result",
tool_use_id: "toolu_01abc...",
content: "[Previous tool call output omitted: used read_file]"
}]
}
而最近 5 次工具结果还是原文。整个 messages 体积比不压缩的版本能小一个数量级,具体省多少完全取决于工具返回的大小,但只要是 coding agent,省下来的 token 是肉眼可见的。
micro-compact 的优势是纯本地、零成本 :没有调 LLM,没有调外部接口,就是字符串替换。劣势也明显:它只把 tool_result 替换掉了,assistant 消息里的思考和文本块还全保留着。如果会话长到 assistant 消息本身就大几万 token,micro-compact 就顶不住了。这种时候要上 Part 4 的 auto-compact。
🟢 在线试一下(跑一个长任务,观察 messages 里旧 tool_result 被 stub 的过程)→ Part 3 Playground
Part 4:auto-compact middleware · 用 LLM 把整段历史压成摘要
micro-compact 顶不住的场景上一节已经预告了:会话长到 assistant 自己的思考 / 文本块就大几万 token,stub 掉 tool_result 也救不回来。这时候我们要的不是"局部省一点",而是把整段历史压扁。
最直接的做法是再请一次 LLM 出场:让它读完整段历史,写一份保留关键信息的摘要,把整个 messages 数组替换成"一条带摘要的 user 消息 + 一条简短的 assistant 应答"。下一次再调 LLM 时,主循环看到的就只有这两条消息(加上后续新加的内容),token 总数瞬间回落到一个安全水位。
这里有两个细节我们先想清楚:
第一,为什么压缩之后是 user + assistant 一对,而不是单独一条 user?因为 Anthropic / OpenAI 协议都要求消息列表里 user / assistant 交替出现。如果我们替换成单条 user 消息,下次真用户又发一条 user,相邻两条 user 协议就会拒。配上一条 assistant 应答("Understood. Continuing with context from the summary.")一切都对得上。
第二,总结这件事本身要不要也走我们的 main provider ?理论上可以,但摘要任务远比主对话简单,调一次便宜模型(比如 Claude Haiku)就够,能省下大头费用。所以我们把 summaryModel 留成一个可选参数。
完整代码
ini
interface AutoCompactOptions {
// 调谁来生成摘要:传一个独立的 LLMProvider,业务里常传便宜模型省钱
provider: LLMProvider;
// token 阈值;默认 120k 是为了给"chars/4"这种粗估留余量
threshold?: number;
// 自定义 token 计数器;不传就用 chars/4 这种粗估
countTokens?: (messages: Message[]) => number;
// 摘要生成后回调,让 backend 等持久化层把摘要写到 DB
onCompacted?: (replacement: [UserMessage, AssistantMessage]) => void | Promise<void>;
}
const ACK_TEXT = "Understood. Continuing with context from the summary.";
function autoCompactMiddleware(options: AutoCompactOptions): AgentMiddleware {
const { provider, onCompacted } = options;
const threshold = options.threshold ?? 120_000;
const countTokens = options.countTokens ?? defaultCountTokens;
return {
// 钩子选 beforeModel:调 LLM 之前先称重,超阈值就先压缩再走主循环
beforeModel: async ({ messages }) => {
const estimated = countTokens(messages);
if (estimated < threshold) return;
// 把历史摊平成一段纯文本,喂给 summary 模型
const text = serializeForSummary(messages);
const response = await provider.invoke({
messages: [
{ role: "system", content: [{ type: "text", text: "You are a conversation summarizer for coding agent sessions." }] },
{ role: "user", content: [{ type: "text", text: buildCompactPrompt(text) }] },
],
});
const summary = extractText(response) || "(summary unavailable)";
const summaryUser: UserMessage = {
role: "user",
content: [{ type: "text", text: `[Conversation Summary]\n\n${summary}` }],
};
const ackAssistant: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: ACK_TEXT }],
};
// 原地清空再 push,保留 messages 数组的引用(Part 3 已经定下的约定)
messages.length = 0;
messages.push(summaryUser, ackAssistant);
if (onCompacted) await onCompacted([summaryUser, ackAssistant]);
},
};
}
// 极粗的 token 估算:每 4 个字符算 1 token。对代码和中文都偏低,所以默认阈值留了余量
function defaultCountTokens(messages: Message[]): number {
return Math.ceil(JSON.stringify(messages).length / 4);
}
serializeForSummary / buildCompactPrompt / extractText 是几个直白的辅助函数(把 messages 转成纯文本、构造摘要 prompt、从返回里抽 text 块),实现没什么巧的地方,篇幅原因这里不展开贴,完整版在 code-artisan 仓库里。
最想单独说的一处是 onCompacted 这个回调。它不是装饰,而是把"压缩这一动作"和"压缩后果如何持久化"显式地解耦。middleware 自己只管做最直接的事:检测、调 LLM、替换 messages。至于摘要要不要存到数据库、要不要发埋点、要不要打个日志,这些 middleware 一概不知道。任何想插手的调用方传一个 onCompacted 回调就行。
这种解耦带来一个直接好处:本地跑实验时我们不传 onCompacted,middleware 照样能跑;接到 backend 上需要持久化时,传一个写 DB 的回调,middleware 代码一行不用动。
实际效果
把三个 middleware 全挂上:
less
const agent = new Agent({ provider: mainModel, tools, toolImpls });
agent.use(timingMiddleware());
agent.use(loopDetectionMiddleware());
agent.use(microCompactMiddleware({ keepRecent: 5 }));
agent.use(autoCompactMiddleware({
provider: cheapModel, // 用便宜模型生成摘要
threshold: 100_000, // 估算超过 10 万 token 就触发
}));
await agent.run({ signal });
跑一个跨越几十步的长会话,触发瞬间的输出大致是:
ini
[timing] model call took 645ms
[timing] tool read_file took 14ms
[timing] model call took 712ms
... (跑了 30 多步之后)
[auto-compact] threshold reached (estimated 102348 tokens), summarizing...
[auto-compact] summary length = 1843 chars, messages.length: 67 → 2
[timing] model call took 423ms // 压缩后下一次调主模型,token 数瞬间清爽
messages.length: 67 → 2 就是这套机制最直观的体感。67 条历史压成 2 条,代价是 cheap model 一次摘要调用(几毛钱),换回的是接下来还能继续走几十轮。
四个 middleware 叠在一起,每一个的职责都很窄:timing 管耗时、loop-detection 管死循环、micro-compact 管轻量压缩、auto-compact 管重量级压缩。它们彼此之间不知道对方存在,主循环也没为它们任何一个加过一行特例代码。这才是我们把横切关注点搬出主循环的真正回报。
🟢 在线试一下(看 4 个 middleware 协同工作 + auto-compact 触发瞬间)→ Part 4 Playground
写在最后
到这一篇结束,我们的 Agent 已经从"会用工具的 Coding Agent"升级成了带横切支撑的可生长 SDK:
- Part 1 :middleware 接口 + 主循环织入 · 4 个钩子位 +
shouldStop干净退出开关 - Part 2:loop-detection · MD5 hash 滑窗 + warn / hard 两档阈值
- Part 3:micro-compact · 旧 tool_result 替换占位符的轻量压缩
- Part 4:auto-compact · 触发阈值后调 LLM 做摘要,messages 数组重置成 2 条
四个 Part 的代码加起来不算多,但骨架已经具备了继续往后扩 的能力。code-artisan 真身的 8 个钩子和上面这 4 个完全是同一套设计思路,只是再多了 beforeAgentRun / afterAgentRun / beforeAgentStep / afterAgentStep 几档时机,可以挂诸如"加载 skills"、"启动 todo 跟踪"这种需要 run 级别 / step 级别钩子才合理的逻辑。后面 06 我们会专门拆一篇 skills 系统。
下一篇我们换个方向:sandbox 。前三篇里所有的 tool 我们都直接 node:fs / child_process 跑在 Node 进程里,这在本地实验没问题,但放到任何要让用户 / 外部 Agent 调用的产品里都不能这么干。一条 bash 工具就能让用户读到服务端文件、改服务端配置。第 5 篇会把 builtin 工具背后的执行层抽象成 Sandbox 接口,我们sdk默认给一个 LocalSandbox 实现,再接一份 E2BSandbox 展示工具系统怎么换底而上层零改动。
完整代码在 GitHub:github.com/lhz960904/c...(这篇文章拆的核心文件是 packages/agent/middlewares/ 和 packages/agent/types/middleware.ts)。如果觉得这个项目对你有帮助,欢迎点个 star ⭐。