Agent 写代码已经做得不错了,但排查内存泄漏一直是它够不着的地方。原因非常具体:堆快照动辄几百 MB,你既没法把它塞进上下文,塞进去之后模型也只是对着一片 JSON 猜。于是现实分工退化成了「AI 写代码、人开 DevTools」——你本人在那儿按保留大小排序、翻几千行、跟代码交叉比对,而 Agent 在旁边等着。Chrome DevTools MCP 近几个版本补的正是这个缺口:它新增了结构化的堆查询能力,让 Agent 可以直接问「哪些对象保留内存最多」「这个节点的保留路径通向哪段应用代码」,拿到的是几十行结构化结果而不是几百 MB 原文;同时补上了安全闸门——可以彻底关掉由 MCP 工具发起的 JavaScript 求值,并把文件访问限定在你指定的根目录内。本教程把官方推荐的三快照流程固化成一次可重复的 Agent 对话:从装服务、开权限、抓基线到定位保留链与验证修复,每一步都能照着跑完。
Step 1:装服务,并把权限闸门一次开对
接入方式和大多数 MCP 服务一致:在客户端的 mcpServers 配置里声明一条 npx 命令即可。但内存排查这一场景,默认配置既不趁手也不安全,有三个开关值得一次设好。其一是内存工具组——官方配置页里,与内存相关的工具默认并未全量开放,需要显式打开内存调试开关才会挂上堆快照相关工具;不打开的话,你会发现 Agent 根本找不到抓快照的工具。其二是 JavaScript 求值——关掉之后,由 MCP 工具发起的脚本求值、navigate_page 的 initScript 参数以及 javascript: 类地址都会被禁用,Agent 仍然能读 DOM、读样式、读网络与无障碍树,只是不能执行脚本。
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": [
"-y", "chrome-devtools-mcp@latest",
"--memory-debugging",
"--no-javascript-evaluation",
"--workspace=/Users/you/project",
"--isolated",
"--no-usage-statistics"
]
}
}
}
其三是文件根目录。读写类工具(保存快照、trace、截图)默认只能访问操作系统临时目录,越界会直接报「不在任何已配置根目录内」。用 --workspace(等价别名 --filesystem-root)指定你的项目目录,可以重复传多个。旧的 --allow-unrestricted-paths 已被官方标记弃用,需要放开时改用 --workspace=/,但内存排查并不必要放开到根。改完配置记得重启客户端,然后在对话里让它列一次工具,确认堆快照相关工具真的挂上了再往下走——「配置写了没生效」是这一步最高频的故障。
关掉求值不等于关掉页面脚本:这个开关只禁用由 MCP 工具发起的 JavaScript 求值,网页自身的脚本照常运行,其它 DevTools 操作也都不受影响。它防的是「Agent 误触发分析事件、误提交表单、误改线上状态」,不是沙箱。排查生产页面时还要额外配合 --allowed-url-pattern 限制可访问的网址(需 Chrome 149 及以上),并且只用一次性 profile 或测试账号。
Step 2:先定动作与预算,再动手抓快照
内存排查最容易自欺的地方是先抓快照、后想标准——看到什么涨了就朝那边找,最后总能「找到」一个解释。正确顺序是倒过来:先声明一个可逆动作、一个循环次数、一组通过阈值,然后再开始。可逆动作的意思是它一定有反向操作:打开弹窗对应关闭弹窗,挂载路由对应离开路由,订阅对应退订,启动任务对应结束并释放。循环次数建议十次上下,通过阈值按你的业务定,比如十次开关之后不得多出两个以上弹窗根节点、监听器集合不得持续增长、清理后保留内存增长不超过 5 MB。这些数字是因 workload 而异的,重要的是在看到结果之前就定死。
# 写进 Agent 的系统提示词或本次对话的开场白
排查协议(照此执行,不许跳步):
1. 可疑动作:打开「订单详情」弹窗;反向动作:关闭弹窗
2. 循环次数:10
3. 通过预算:清理后保留内存增长 < 5 MB,弹窗根节点不多于 2 个,
监听器数量不随循环单调增长
4. 抓三张快照:基线 / 目标(循环后)/ 收尾(反向动作并静置后)
5. 先比摘要,再下钻到一条保留链,最后给出最小修复
6. 修复后重放同一协议,达不到预算就报告剩余增长与下一条假设
把这一步写清楚还有个附带好处:它把 Agent 从「帮我看看有没有内存泄漏」这种开放式请求,变成了有验收条件的任务。开放式请求下,模型最常见的行为是抓一张快照、指出几个占内存大的类名,然后给出一段听上去合理但无法证伪的分析;有了预算与三快照协议,它必须回答「清理后还剩多少」,这是一个可判定的问题。
Step 3:抓三张快照,中间不许换条件
三张快照是这个流程的核心设计。基线是应用稳定之后、什么都没做时的状态;目标是重复可疑动作十次之后的状态;收尾是执行反向动作、并静置足够长时间让正常清理发生之后的状态。三张之间必须保持同一构建、同一 profile、同一页面、同一输入、同一循环次数——任何一项变了,这次实验就作废。特别注意不要在中间刷新页面或重启浏览器,那样会把「泄漏」和「重新初始化」混在一起,结论没法辩护。
# 让 Agent 依次执行(工具名以你安装的版本为准)
take_heapsnapshot # 1) 基线:应用稳定后
# → 执行十次「打开弹窗 / 关闭弹窗」
take_heapsnapshot # 2) 目标:循环结束
# → 执行一次反向动作,静置等待常规回收
take_heapsnapshot # 3) 收尾:清理完成后
# 用完务必释放,包括被否掉的假设产生的快照
close_heapsnapshot ./xxx.heapsnapshot
这里有个容易忽略的成本问题:快照文件本身会占用内存与磁盘,而且路径里带着应用字符串与对象数据。三张快照加上若干次下钻,很容易攒出一堆几百 MB 的文件。请让 Agent 把快照落在临时目录而非项目目录,避免误进版本库;排查结束后统一 close 并清理。把快照路径写进提示词或提交信息,等同于把生产数据外泄。
不要把原始快照文件喂给模型:官方技能说明明确提醒,原始 .heapsnapshot 体积极大,应当走结构化的摘要、对比、类、对象与保留路径工具,而不是把文件读进上下文。你让它「读一下这个快照」,结果通常是要么截断、要么烧掉一大截上下文还什么都没看明白。
Step 4:先比摘要,再决定要不要下钻
拿到三张快照之后,先让 Agent 对三张各跑一次摘要,比较总大小、对象数量、主要类名与原生执行上下文,再做基线与目标、基线与收尾两次对比。这一步的判断逻辑值得讲清楚:目标快照比基线大是正常的——动作本身会分配内存,尤其是初次渲染一个复杂组件。真正指向泄漏的信号是「执行了反向动作、也等过了清理,收尾阶段仍然残留的增长」。如果某个独立的原生上下文吃掉了大部分增量,先按它过滤,再看单个对象,否则你会把扩展、控制台引用或诊断服务自己的内存算到应用头上。
# 摘要与对比(结构化返回,不读原文)
get_heapsnapshot_summary ./base.heapsnapshot
get_heapsnapshot_summary ./target.heapsnapshot
get_heapsnapshot_summary ./final.heapsnapshot
compare_heapsnapshots ./base.heapsnapshot ./target.heapsnapshot
compare_heapsnapshots ./base.heapsnapshot ./final.heapsnapshot
# 按保留大小直接问:谁占得最多
query_heapsnapshot ./final.heapsnapshot --by-retained-size top 10
# 也可以按属性名问:怀疑某类对象泄漏时用
query_heapsnapshot ./final.heapsnapshot --property-name closure
query_heapsnapshot 是这批更新里最省事的一个:它会按保留大小或属性名直接返回结构化结果,还能给出边的自身大小与保留大小、按保留大小排序并过滤掉原始类型。以前的做法是导出整个堆让模型去猜,现在的做法是让模型问一个具体问题。给 Agent 写提示词时,建议直接点名结论条件,比如「若收尾相对基线的保留增长小于 5 MB 且无单调增长的监听器集合,则判定无泄漏」,比空喊「认真分析一下」有效得多。
Step 5:追一条保留链,直到落到应用代码
选定一个嫌疑类之后,套路是固定的:列出该类的实例,挑收尾快照里一个有代表性的节点,看它的对象详情,然后依次查保留者、最短保留路径、支配树与边,直到这条链指向你自己代码持有的状态。中途可以用类别过滤验证具体假设——被分离的 DOM 节点保留、事件处理器保留、执行上下文保留、控制台引用保留,这几种是最常见的泄漏形态。只有当字符串增长在对比中占主导时,才需要去看重复字符串分析。
# 1) 列出嫌疑类的实例
get_heapsnapshot_class_nodes ./final.heapsnapshot "DetachedHTMLDivElement"
# 2) 挑一个代表节点看详情
get_heapsnapshot_object_details ./final.heapsnapshot 12345
# 3) 追保留链:谁在持有它,最短路径是什么
get_heapsnapshot_retainers ./final.heapsnapshot 12345
get_heapsnapshot_retaining_paths ./final.heapsnapshot 12345
# 4) 看支配树与边,确认真正的持有者
get_heapsnapshot_dominators ./final.heapsnapshot 12345
get_heapsnapshot_edges ./final.heapsnapshot 12345
这一步最常见的翻车是「改了一个分离节点就算修好了」。分离 DOM 有时是框架有意保留的缓存或动画中间态,删掉它只是把症状按住。判断标准很简单:保留链必须通向你自己代码里某个本该释放的引用——一个没摘的监听器、一个没清的定时器、一个没退订的订阅、一个没设上限的缓存、一个没删的 Map 条目、一个没释放的 observer、或者一个捕获了旧状态的闭包。链不到应用代码,就换下一条假设,不要硬改。
别把浏览器进程内存和堆保留大小混为一谈:两者不是同一个指标。另外 DevTools 本身、扩展、控制台引用以及诊断服务都会保留对象,所以「总量涨了」不等于「应用泄了」。看到涨幅先问一句「谁持有它」,再动手改代码。
Step 6:做最小修复,然后重放同一套协议
修复的原则是最小的所有权修复:改掉那条本该被释放的应用自有边,而不是把支配堆的框架状态删掉。一次只改一处,然后重放完全相同的三快照协议,比较完全相同的指标与类。只有当重放结果满足你在 Step 2 定下的预算时,才算通过;否则报告剩余增长与下一条保留链假设,继续下一轮。这个纪律看着笨,但它避免了「改了三处、好了、不知道是哪处在起作用」这种最昂贵的假成功。
# 典型的最小修复(挑对应的那一条,不要一次全上)
element.removeEventListener('scroll', onScroll) // 摘监听器
clearInterval(timerId) // 清定时器
subscription.unsubscribe() // 退订
cache.delete(key) // 删缓存条目
observer.disconnect() // 释放 observer
map.delete(id) // 删 Map 条目
收尾别忘了两件事。一是把本次所有加载过的快照 close 掉,包括那些被否掉的假设产生的——忘记释放会让诊断服务自己占着一大块内存,干扰下一轮。二是把结论写成一条可复制的记录:日期、提交号、目标地址、快照存放位置与保留期、可疑动作与反向动作、循环次数、通过预算、三张快照的摘要、最大的原生上下文增量、嫌疑类与代表节点、通向应用代码的保留路径、做的修复与回归检查、重放结果、以及剩余风险与下一条假设。这份记录既是交接材料,也是下次回归的基线。
工具名与参数会随版本变:MCP 工具名、--memory-debugging 这类开关的默认状态、以及 --allow-unrestricted-paths 是否仍可用,在不同小版本间都有过调整,官方已把后者标记为弃用并建议改用 --workspace=/。请以你安装的版本为准,跑一遍 npx chrome-devtools-mcp@latest --help 对照,不要照抄几个月前的博客片段。
常见问题速查
| 现象 | 原因与处理 |
|---|---|
| Agent 说找不到抓快照的工具 | 内存调试开关没打开或客户端没重启;确认配置生效后再列一次工具 |
| 保存快照时报路径不在根目录内 | 默认只允许系统临时目录;加 --workspace 指定项目目录,可重复传多个 |
| 目标比基线大,但是不是泄漏说不清 | 看收尾快照;反向动作并静置后仍残留的增长才是强信号 |
| 把快照文件读进上下文后什么也没分析出来 | 原始快照体积极大;改用摘要、对比、类、对象与保留路径等结构化工具 |
| 涨的量来自某个原生上下文 | 先按该上下文过滤,再看单个对象;否则会把扩展、控制台的账算到应用头上 |
| 改完分离节点但数字没降 | 可能是框架有意保留的缓存;保留链要落到应用自有引用才算数 |
| 下一轮排查基线明显偏高 | 上轮快照没 close;统一释放后重开一次干净进程 |
| 排查生产页面时担心误操作 | 关掉 JavaScript 求值 + 限制网址模式 + 一次性 profile 或测试账号 |
| 不确定某个开关叫什么 | 跑 npx chrome-devtools-mcp@latest --help 对照官方文档,别照抄旧博客 |