
一、基础概念
1.1、Agent Hook
这是什么?
Agent Hook 是在 Agent 执行循环的关键节点上插入的回调函数------让外部代码能在"模型即将推理前""工具即将执行前""这一轮结束时"等时机介入,做拦截、修改、记录或中止。
把 Agent Loop 想象成一条流水线,Hook 就是流水线上的一道道"检查站"------每个检查站都可以放行、修改、拦截或中止。
如果没有 Hook,Agent 就是一个"黑盒":你给它任务,它自己跑到结束,中间发生了什么、调了什么工具、花了多少钱,你既看不见也管不了。Hook 就是打开这个黑盒的开关。
主要解决的问题
| 问题 | 没有 Hook | 有 Hook |
|---|---|---|
| 可观测性 | 不知道 Agent 中间做了什么 | postModelHook / postToolCallHook 记录每一步 |
| 成本控制 | 模型可能无限循环烧钱 | preModelHook 里检查 token 用量,超预算就停 |
| 安全审批 | 危险工具(删文件/发请求)直接执行 | preToolCallHook 拦截危险工具,等用户批准 |
| 人为介入 | 模型卡住只能干等 | 在任意节点 interrupt 暂停,等用户给信息再恢复 |
| 上下文管理 | 历史无限增长导致 token 爆炸 | preModelHook 里检测并触发摘要压缩 |
1.2、Checkpointer
这是什么?
Checkpointer 是 Agent 执行状态的存档/读档机制------像游戏里的"存档点"。每执行完一个节点(node),Checkpointer 就把当前完整的 State(消息历史、变量、工具结果)持久化到存储(内存/SQLite/Postgres)。任何时候你都能"读档"回到任意一个存档点。
Hook 是"实时介入",Checkpointer 是"事后回溯"。两者经常配合:Checkpointer 存档 → 用 Hook 在某个存档点暂停 → 用户介入 → 从该存档点恢复执行。
主要解决的问题
| 问题 | 没有 Checkpointer | 有 Checkpointer |
|---|---|---|
| 进程崩溃 | Agent 跑到一半挂了,全部重来 | 从最近的 checkpoint 恢复,继续执行 |
| 长任务断点续跑 | 跑 2 小时的任务必须一次性跑完 | 随时暂停、随时恢复 |
| 时间旅行调试 | 不知道哪一步出错了 | 回退到出错前的 checkpoint,重放观察 |
| 分支实验 | 想试"如果这步换种做法会怎样" | 从某 checkpoint fork 出新分支跑 |
| 人机协作 | 暂停等用户审批后恢复 | interrupt 暂停时自动存档,恢复时自动读档 |
二、Hooks - 掌握执行操作/方向的可能性
2.1、在 Agent Loop 当中增加运行钩子 Hook
LangGraph 的 createAgent 支持 preModelHook / postModelHook 两类钩子,分别在"模型推理前"和"模型推理后"触发。返回值可以更新 State 或注入消息:
typescript
import { createAgent } from '@langchain/langgraph';
import { ChatAnthropic } from '@langchain/anthropic';
import { Annotation } from '@langchain/langgraph';
// ① 定义 State(带一个 turnCount 字段,用于在 Hook 里计数)
const StateAnnotation = Annotation.Root({
...Annotation.MessagesState.spec, // 内置 messages 数组
turnCount: Annotation<number>({ default: () => 0, reducer: (a, b) => a + b }),
});
const agent = createAgent({
llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
tools: [/* ... */],
stateSchema: StateAnnotation,
// ② preModelHook:模型推理前触发
// 返回的对象会被 merge 进 State(这里累加轮次 + 注入提醒)
preModelHook: (state) => {
console.log(`[Hook] 即将进行第 ${state.turnCount + 1} 轮推理`);
// 成本控制:超过 50 轮就强制注入"请尽快收尾"的提醒
if (state.turnCount >= 50) {
return {
turnCount: 1,
messages: [{
role: 'system',
content: '⚠️ 已达 50 轮,请在下一轮给出最终答案。',
}],
};
}
return { turnCount: 1 };
},
// ③ postModelHook:模型推理后、工具执行前触发
// 可以看到模型本轮的决定(有没有 tool_calls)
postModelHook: (state) => {
const lastMsg = state.messages.at(-1);
if (lastMsg?.tool_calls?.length) {
console.log(`[Hook] 模型决定调用: ${lastMsg.tool_calls.map(t => t.name).join(', ')}`);
} else {
console.log('[Hook] 模型本轮无工具调用,即将输出最终答案');
}
// 返回 undefined 表示不修改 State
},
});
Hook 的执行时机图:
csharp
用户输入
↓
[preModelHook] ← 你可以在这里修改 messages、注入提醒、检查预算
↓
模型推理(stream)
↓
[postModelHook] ← 你可以在这里记录模型决定、拦截危险 tool_call
↓
工具执行
↓
回到 preModelHook(下一轮循环)
2.2、审批处理 Hook
判断需要审批的情况
并非所有工具调用都需要审批------只读工具(查询、搜索)可以直接放行,而有副作用的工具 (删文件、发请求、改配置、花钱)应该拦截等用户批准。实现方式是在 postModelHook 里检查 tool_calls,遇到危险工具就触发 interrupt:
javascript
import { createAgent, interrupt, Command } from '@langchain/langgraph';
// 定义哪些工具需要审批
const DANGEROUS_TOOLS = new Set(['delete_file', 'execute_command', 'send_email']);
const agent = createAgent({
llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
tools: [/* ... */],
postModelHook: (state) => {
const lastMsg = state.messages.at(-1);
const calls = lastMsg?.tool_calls ?? [];
// 找出本轮调用中需要审批的危险工具
const dangerous = calls.filter(c => DANGEROUS_TOOLS.has(c.name));
if (dangerous.length > 0) {
// interrupt:暂停执行,把审批请求抛给外部
// 外部(UI)会展示"模型想调用 delete_file,是否批准?"
const decision = interrupt({
type: 'approval_request',
toolCalls: dangerous.map(c => ({
name: c.name,
args: c.args,
})),
message: `模型请求执行 ${dangerous.length} 个危险操作,请审批`,
});
// decision 是用户在外部恢复时传入的决定
if (decision === 'allow_once') {
console.log('[审批] 用户:本次允许');
// 不做任何修改,正常继续执行
} else if (decision === 'allow_session') {
console.log('[审批] 用户:本会话允许');
// 可以在这里把工具加入白名单(简化示例省略状态持久化)
} else {
console.log('[审批] 用户:拒绝');
// 拒绝:把 tool_calls 从消息里移除,注入"用户拒绝"的 ToolMessage
return {
messages: [{
role: 'tool',
content: '用户拒绝了此操作。',
tool_call_id: dangerous[0].id,
}],
};
}
}
},
});
审批操作行为的记录
单次允许
用户选"单次允许"------只放行这一回,下次再调同一个工具还是要审批。适合偶尔用的高危操作(如删除生产数据)。
对话允许
用户选"本会话允许"------把工具加入当前会话的白名单,后续不再弹审批。适合用户信任后想连续操作的场景(如批量重命名文件)。
拒绝
用户选"拒绝"------把这个 tool_call 拦截掉,注入一条 ToolMessage 告诉模型"用户拒绝了这个操作",模型会据此调整后续行为(比如换一种方案或询问用户原因)。注意:拒绝不是中止 Agent,Agent 会继续运行,只是这一步被否决了。
2.3、询问确认 Hook
不确定的处理方向
有时模型不是要执行危险操作,而是自己拿不准该走哪个方向------比如用户的需求模糊,或有多个可行方案。这时可以用 interrupt 主动暂停,向用户提问:
php
const agent = createAgent({
llm: model,
tools: [/* ... */],
// 用一个专门的"问用户"工具,模型遇到歧义时调用它
// 这里通过 postModelHook 检测到 ask_user 工具被调用,触发 interrupt
postModelHook: (state) => {
const lastMsg = state.messages.at(-1);
const askCall = lastMsg?.tool_calls?.find(c => c.name === 'ask_user');
if (askCall) {
// interrupt 暂停,把问题抛给用户
const answer = interrupt({
type: 'question',
question: askCall.args.question,
options: askCall.args.options, // 可选:预设选项
});
// 用户回答后恢复,把答案作为 tool 结果注入
return {
messages: [{
role: 'tool',
tool_call_id: askCall.id,
content: answer,
}],
};
}
},
});
单选/多选/自定义处理
ask_user 工具的 schema 可以声明不同的回答模式,UI 据此渲染不同交互:
php
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
const askUser = tool(
// 这个工具的真正"执行"由 postModelHook 的 interrupt 接管,这里只是占位
async () => '等待用户回答',
{
name: 'ask_user',
description: '当你不确定用户意图时,向用户提问。不要自己瞎猜。',
schema: z.object({
question: z.string().describe('要问用户的问题'),
mode: z.enum(['single', 'multiple', 'text']).describe(
'single=单选, multiple=多选, text=自由文本',
),
options: z.array(z.string()).optional().describe(
'当 mode 为 single/multiple 时的候选项',
),
}),
},
);
| mode | UI 渲染 | 例子 |
|---|---|---|
single |
单选按钮组 | "用 React 还是 Vue?" → React Vue |
multiple |
多选复选框 | "要导出哪些格式?" ☑PDF ☑HTML ☐DOCX |
text |
文本输入框 | "你的目标用户是谁?" → ____ |
实际应用中,桌面端(Electron)和终端端(TUI)通常各自把 interrupt 事件渲染成对应的 UI 组件------前者弹出审批对话框,后者用终端全屏 overlay 展示选项。
三、Checkpointer - 掌控对话存档与回溯之力
承继着前面的 Agent Hook,我们能够基于这个点进一步的实现一个类似存档的能力。
3.1、快照存档点
LangGraph 的 Checkpointer 在每个 node 执行完毕后自动存档 ------你不需要手动调用 save。只要在 compile 时传入一个 checkpointer 实例,存档就自动开启。
javascript
import { createAgent } from '@langchain/langgraph';
import { MemorySaver } from '@langchain/langgraph';
import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite';
import * as sqlite from 'node:sqlite';
// ① 选择存档后端
// MemorySaver:存内存,进程退出就丢(适合开发调试)
const memoryCheckpointer = new MemorySaver();
// SqliteSaver:存 SQLite,进程重启后可恢复(适合生产)
const db = new sqlite.DatabaseSync('checkpoints.sqlite');
const sqliteCheckpointer = SqliteSaver.fromConn(db);
// ② 把 checkpointer 传给 Agent
const agent = createAgent({
llm: new ChatAnthropic({ model: 'claude-sonnet-4-20250514' }),
tools: [/* ... */],
checkpointer: sqliteCheckpointer, // ← 关键:传入后自动存档
});
// ③ 每次调用要带一个 thread_id(会话标识)
// 同一个 thread_id 的多次调用共享同一份存档历史
const config = { configurable: { thread_id: 'session-001' } };
// 第一次调用:执行到一半时被 interrupt 暂停(比如等审批)
const result1 = await agent.invoke(
{ messages: [{ role: 'user', content: '帮我删除 /tmp/old.log' }] },
config,
);
// 此时 Agent 暂停在审批 interrupt 处,状态已存档
// 用户批准后,第二次调用:从存档点恢复,继续执行
const result2 = await agent.invoke(
new Command({ resume: 'allow_once' }), // 传入审批决定
config, // 同一个 thread_id → 读档
);
// Agent 从暂停处继续,执行删除操作,输出最终结果
存档的内容:每次存档保存完整的 State 快照------包括 messages 数组、所有自定义变量、当前执行到哪个 node、interrupt 状态。这意味着你可以在任意时刻"读档"回到任何一个历史节点。
3.2、读档回溯重来
存档是为了能在需要时回退到历史状态 。LangGraph 提供两套 API:getStateHistory 列出某 thread 的所有 checkpoint,updateState 把 State 回退/修改到指定版本。
perl
import { Agent } from './my-agent'; // 上面创建的 agent 实例
const config = { configurable: { thread_id: 'session-001' } };
// ① 列出该 thread 的所有 checkpoint(按时间倒序)
const history = await agent.getStateHistory(config);
for (const state of history) {
console.log({
checkpointId: state.config.configurable.checkpoint_id,
step: state.values.step ?? 0, // 第几步
messages: state.values.messages.length, // 这一步的消息条数
next: state.next, // 下一步要执行的节点
createdAt: state.metadata?.createdAt,
});
}
// 输出示例:
// [
// { checkpointId: '1f2c...', step: 5, messages: 12, next: ['tools'], createdAt: '...' },
// { checkpointId: 'a8d1...', step: 4, messages: 10, next: ['agent'], createdAt: '...' },
// { checkpointId: '5e3b...', step: 3, messages: 8, next: ['tools'], createdAt: '...' },
// ...
// ]
回退到指定 checkpoint :用 updateState 选一个历史版本作为新起点,然后把修改后的 State 作为下一次 invoke 的输入。LangGraph 会从那个 checkpoint 重放后续节点,而不是简单"跳过去"------这保证 State 一致性。
arduino
// ② 找到出错前的那一步(比如第 4 步)
const targetCheckpoint = history.find(s => s.values.step === 4);
// ③ 从这个 checkpoint 恢复,并修改 State(可选)
await agent.updateState(
targetCheckpoint.config, // 要恢复到的 checkpoint 配置
{
// 覆盖 State 字段(这里是纠正用户的错误输入)
messages: [
...targetCheckpoint.values.messages.slice(0, -1), // 去掉最后那条坏消息
{ role: 'user', content: '正确的问题描述' },
],
},
);
// ④ 再次 invoke------LangGraph 会从更新后的 State 继续执行
const result = await agent.invoke(
null, // 不传新输入,从 checkpoint 恢复
targetCheckpoint.config,
);
关键点 :updateState 是修改 + 重放,不是"删除历史"。原 checkpoint 还在 store 里,你随时可以回到任何历史版本。生产环境的 Checkpoint store 通常保留所有历史(按 thread_id 索引),UI 上可以可视化时间线让用户选择回退点。
3.3、分叉实验:从一个 checkpoint 跑出多个分支
存档的另一个高级用法是分支实验(fork) ------从同一个 checkpoint 出发,复制多份分别跑不同方案,最后对比结果。这在 prompt 调优、A/B 测试、方案对比时非常有用。
javascript
// 场景:模型在第 4 步生成了"建议 A"和"建议 B"两个分支,
// 想分别跑完看哪个效果好
// ① 找到第 4 步的 checkpoint
const checkpoint4 = (await agent.getStateHistory(config))
.find(s => s.values.step === 4);
// ② 分叉 1:跑"建议 A"路线
await agent.updateState(checkpoint4.config, {
// 注入"建议 A"作为模型下一轮的输入
messages: [...checkpoint4.values.messages, {
role: 'user',
content: '走方案 A:用 TypeScript 重写',
}],
});
const branchA = await agent.invoke(null, checkpoint4.config);
// ③ 分叉 2:跑"建议 B"路线(注意用不同的 thread_id,否则会覆盖)
const branchBConfig = {
configurable: {
thread_id: 'session-001-branch-B', // 不同的 thread_id
checkpoint_id: checkpoint4.config.configurable.checkpoint_id,
},
};
await agent.updateState(branchBConfig, {
messages: [...checkpoint4.values.messages, {
role: 'user',
content: '走方案 B:用 Python 重写',
}],
});
const branchB = await agent.invoke(null, branchBConfig);
// ④ 对比两个分支的最终结果
console.log('方案 A:', branchA.messages.at(-1).content);
console.log('方案 B:', branchB.messages.at(-1).content);
为什么分叉要用不同 thread_id :thread_id 是 checkpoint 的"命名空间",同名会覆盖。生产场景下 fork 通常配合新的 thread_id(如 xxx-branch-1、xxx-branch-2),让两条线可以独立读档、独立存档,方便后续对比和回溯。
3.4、读档回溯的副作用与幂等性
在读档 Checkpoint 恢复时,节点可能被重新执行(replay)。如果节点有副作用(发请求、写文件、扣款),重放会重复执行!
解决副作用的一个形式:
幂等性设计 ------给每个工具调用附带一个唯一的 id(比如用 tool_call_id),执行时先查这个 id 是否已经执行过(用 Checkpoint 里的记录或外部状态库)。执行过就直接返回上次的结果,不真正再执行一遍。
typescript
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
// 已执行操作的记录(生产环境用 SQLite/Redis 持久化)
const executedOps = new Map<string, any>();
const sendEmail = tool(
async ({ to, subject, body }, config) => {
const callId = config.toolCallId; // 每次调用的唯一 id
// 幂等检查:这个 callId 执行过吗?
if (executedOps.has(callId)) {
console.log(`[幂等] ${callId} 已执行过,跳过`);
return executedOps.get(callId); // 直接返回上次结果
}
// 真正执行
const result = await mailgun.send({ to, subject, body });
// 记录,防止重放时重复执行
executedOps.set(callId, result);
return result;
},
{
name: 'send_email',
description: '发送邮件(幂等:同一 callId 不会重复发送)',
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
},
);
Checkpoint 的另一面:它存的只是"Agent 的内部状态",不包括"外部世界已经发生的改变"。所以 Checkpoint 回溯 + 副作用工具 = 潜在的重复执行风险。生产实践中对所有有副作用的工具(文件写入、命令执行、扣款、发邮件)都做幂等性处理,是规避此类风险的标准手段。
四、人机协作完整流程
Hook + Checkpointer + interrupt 三者配合,构成 LangGraph 的人机协作(Human-in-the-Loop) 核心机制。完整流程的时序图:
scss
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │ │ Agent │ │ Store │ │ UI │
│ (用户) │ │ (LangGr.)│ │ (Checkpt)│ │ (前端) │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ invoke │ │ │
│─────────────────▶│ │ │
│ │ preModelHook │ │
│ │──┐ │ │
│ │ │记录上下文 │ │
│ │◀─┘ │ │
│ │ 模型推理 │ │
│ │ 决策: 调工具A │ │
│ │ postModelHook │ │
│ │──┐ │ │
│ │ │检测到危险工具 │ │
│ │◀─┘ │ │
│ │ interrupt() │ │
│ │──┐ │ │
│ │ │存档当前 State │ │
│ │ │────────────────▶│ save checkpoint │
│ │◀─┘ │ │
│ │ 抛出 interrupt │ │
│ │─────────────────────────────────────▶ │
│ │ │ 推送审批请求 │
│ │ │ │
│ │ │ 用户决策 │
│ │ │ ◀─────────────── │
│ │ │ │
│ │ │ 用户决定+参数 │
│ │ ◀───────────────────────────────────── │
│ │ invoke(Command) │ │
│ │──┐ │ │
│ │ │读 checkpoint │ │
│ │ │◀───────────────│ load checkpoint │
│ │◀─┘ │ │
│ │ 从中断处继续 │ │
│ │ 工具执行 │ │
│ │ 模型继续推理 │ │
│ │ 最终回答 │ │
│ ◀────────────────│ │ │
│ 显示流式回答 │ │ │
关键时序点:
- interrupt 触发 :模型决定调危险工具 → postModelHook 调用
interrupt()→ LangGraph 立即存档当前完整 State 到 Store → 抛出 interrupt 异常给上层 - UI 接收:上层(Electron / TUI)捕获 interrupt 事件 → 解析出审批请求内容(哪个工具、参数是什么) → 渲染审批对话框
- 用户决策 :用户点"允许/拒绝/修改参数" → UI 把决策包装成
Command({ resume: ... })→ 再次调用agent.invoke() - resume 恢复 :LangGraph 用相同
thread_id找到之前的 checkpoint → 加载 State → 从中断点继续执行 → 工具按用户决策执行 → 模型继续推理 → 最终回答 - 存档保留:所有 checkpoint 都保留在 Store 里,UI 可以让用户"回到任何一步"重新决策
为什么这个流程可靠:
- 状态不丢:中断时已存档,恢复时从存档加载,不会因为进程崩溃或断电丢失中间状态
- 决策可追溯:每次 interrupt 的请求内容、用户决策、时间戳都记录在 checkpoint metadata 里
- 可重放:用户可以"撤销"自己的决策,回到上一步重新选------LangGraph 读 checkpoint 重放后续节点
- 跨进程:存档在 SQLite 等持久层后,A 进程的 interrupt 可以由 B 进程响应(多窗口/远程协作场景)
参考资料:
Agent 开发系列文章
前端转型 Agent 开发 01 之 Agent API 调用(和 Agent 的基础对话):juejin.cn/post/767744...
前端转型 Agent 开发 02 之 Provider 与 Structured Output(规范化模型输入输出):juejin.cn/post/767745...
前端转型 Agent 开发 03 之 Agent Tools(给 Agent 装上手脚):juejin.cn/post/768007...
前端转型 Agent 开发 04 之 MCP 与 Skill(赋予 Agent 更广工作能力):juejin.cn/post/768563...
前端转型 Agent 开发 05 之 Agent Hooks 与 Checkpointer(让 Agent 从全自动转变人为可掌控):juejin.cn/spost/76878...