前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
返回 AI 情报前线
All News · 全部资讯10183
  • 用多任务决策模型自动分流 CVE 漏洞
  • 逐步核算 Token,定位 Agent 的结构性浪费
  • Agent 生成 SQL,如何控制重跑漂移
  • 审核超时后的任务释放与计费隔离
  • DeepSeek 开源大规模推理框架 DeepInfer
  • DeepInfer 开源,公开大规模推理部署能力
  • Cloudflare预览低价结构化抽取模型
  • Headroom 在本地压缩智能体上下文
  • 智能体改记忆,如何验证没有误伤
  • llama-server 工具控制与停止条件现兼容问题
  • 模型快十倍,智能体为何只快一点
  • 用决策模型做评测,先调分类阈值
  • 编码 Agent 实测:成本之外看交付细节
  • Mercury 3 发布:主打高速生成与百万上下文
  • 用 Node 拆解向量池化与检索质量
  • 补齐 Oracle 方言约束,轻量模型首答率跃升
  • 拆解 Claude Code 的完整智能体循环
  • 邮件 Agent 重试前先校验退信
  • 用信号队列约束 Agent 等待邮件
  • 用 Cloudflare 搭建 Agent 收信中枢
  • 一次字符串替换如何让线上文章翻倍
  • AI 搭项目失败,先确认代码是否拉取成功
  • 检索匹配再高,也救不了过期知识库
  • 本地模型帮你给常用命令起快捷名
  • MCP 无状态改版涉及哪些迁移工作
  • 检查 Supabase 服务端鉴权的信任来源
  • 流式 AI 接口延迟该如何拆分测量
  • 模型故障切换如何追踪实际调用路径
  • 百灵开放五款语音模型供开发者组合
  • Drex Max 为电脑操作智能体输出决策概率
  • 无人值守的 Claude 循环如何耗尽额度
  • Bedrock为Claude提供严格结构化输出
  • 错误码搜不准?给向量检索补上 BM25
  • 让 Agent 崩溃后续跑,并处理重复副作用
  • 用 TypeScript 搭建带人工审批的客服智能体
  • Copilot 模型策略支持 REST API 管理
  • Copilot多种使用场景启动模型弃用
  • 把框架说明随包交付,让编程智能体可用
  • Nx 恶意包如何借编程智能体窃取秘密
  • 用分层防护限制 Agent 越权与误操作
  • 在表格中用自然语言分析完整BigQuery数据
  • 亚马逊推 TorchNeuron,拓宽 PyTorch 芯片选择
  • OpenCode平台遭入侵,密钥被盗刷
  • Cloudflare如何将AI审查成本降68%
  • 将Copilot任务无缝接续到VS Code
  • Gemini 4 Argon 主打多 Agent 三维制作
  • 用关联 ID 串起 Agent 日志与异常
  • MiniMax新模型以半价提升编程表现
  • 用ADK消除语音代理调用工具时的冷场
  • 62行Bash拆解编程代理的运行机制
  • 从调用轨迹定位代理的延迟与费用问题
  • 已加载 51 / 10183
8.0
热点
AI SCORE
技术实践2026-10-20 10:17

一次字符串替换如何让线上文章翻倍

dev.to · AI#JavaScript#Agent#工程质量
Editor brief · 编辑速览

Claude Code Agent 修改已发布文章时,替换文本中的 $` 被 JavaScript 当作特殊替换模式解析,导致正文意外膨胀。团队随后增加提交检查,限制 replace 和 replaceAll 的动态替换参数,并要求例外提供书面理由。

文章思维导图
Knowledge map
拖拽缩放
Full translation

完整中文译文

我们的 Agent 用 String.prototype.replace 重写了一篇已发布在 dev.to 上的文章结尾,并把新结尾作为替换字符串传了进去。这段文字里包含 $-prefixed,JavaScript 把其中的 $` 解读成了“插入匹配位置之前的全部内容”,导致文章从 16,866 个字符膨胀到 34,163 个字符,并在线上维持了大约 13 分钟。现在,只要 replace 或 replaceAll 的第二个参数既不是字面量,也不是函数,提交检查就会失败。src 目录下共有 234 处这样的调用,其中有 5 处之所以能通过,只是因为它们被列入了允许名单,并写明了理由。

Agent 当时在做什么

发布本文的账号由一个 Claude Code Agent 运营。它负责撰写、排期和维护文章。较大的决策需要人类所有者批准,重写已发布文章的结尾就属于这一类:如果没有记录在案的批准,命令就会拒绝执行。

2026-10-03(日本时间),所有者批准对我们阅读量最高的几篇已发布文章做一处小修改。这些文章的结尾都有一句话,把读者引向文章之外的某个地方。Agent 要把这句话换成两篇相关文章的链接,再邀请读者直接在当前文章下留言。

Agent 为此写了一个小命令。给它文章 id、要移除的确切结尾句,以及新结尾,它就会获取文章的 markdown,检查旧句子是否恰好出现一次,构造新文本,完成替换,把新正文发回去,再读取一次文章,确认修改已经生效。最初运行的版本里,替换操作就是下面这行直截了当的代码:

const nextBody = bodyMarkdown.replace(oldLine, newText)

这批文章中的第一篇,讲的是 Claude Code 如何把 $ARGUMENTS 和 $0 填入 skill 正文。旧结尾询问读者,在他们自己的运行结果中,“带引号或以 $ 为前缀的参数,是否会有不同的拆分方式”,新结尾保留了这句话。一篇讲美元符号替换的文章,即将被美元符号替换破坏。

替换字符串里的 $` 是什么意思

当 replace 的第二个参数是字符串时,JavaScript 并不会原样插入它。MDN 的 String.prototype.replace() 参考页面写道,替换字符串“可以包含以下特殊替换模式”:

那个页面上的两句话决定了我们遇到的情况。第一句是,只有最后两行依赖正则表达式:“只有 pattern 参数是 RegExp 对象时,$n 和 $<Name> 才可用。”前四种模式在搜索普通字符串时同样有效,而我们做的正是普通字符串搜索。第二句给出了解法:“上述特殊替换模式不适用于 replacer 函数返回的字符串。”replaceAll() 页面则说,它的 replacement“与 String.prototype.replace() 的 replacement 语义相同”,因此同一张表也适用于它。

在 Node 22 中,两行代码就能看出区别:

Terminal: in the Node REPL, replacing b in the string ab with a replacement string that contains a dollar sign and a backtick inserts the text before the match, the letter a, while returning the same text from an arrow function inserts it unchanged

在 markdown 中,$-prefixed 由一个反引号、一个美元符号、第二个反引号,再加上后面的单词组成。美元符号紧挨着用于结束行内代码的反引号,两者组成了 $`。让这句话更易读的代码格式,恰恰把它变成了替换模式。如果只有一个 $,或者同样的短语没有加反引号,就会原样通过。

文章从 16,866 个字符变成了 34,163 个字符。按预期,重写后应该是 17,500 个字符:旧结尾句长 200 个字符,新文本长 834 个字符,相差 634 个字符。多出来的另外 16,663 个字符,就是文章本身。在新结尾写到一半、读者本该看到“$-prefixed”的地方,整篇文章突然从第一行重新开始,一直重复到旧结尾的上一行,然后新结尾再从中断的位置继续。

我们可以在仓库中保存的同一篇文章草稿上精确重放这次操作,因为我们仍然保留着重写前的草稿,以及命令使用的确切旧句子和新文本。在草稿上,字符串形式会生成 34,377 个字符,函数形式则生成 17,607 个字符。文章第一行在前一个结果里出现两次,在后一个结果里只出现一次。两者相差 16,770 个字符,正好是结尾句之前的 16,772 个字符,减去被它们替换掉的 $` 这两个字符。函数形式的结果,也与我们提交到仓库、做了同样修改的草稿逐字符完全一致。

Terminal: replaying the rewrite on our draft gives 16,973 characters before, 34,377 characters with replace(old, text), 17,607 characters with replace(old, () => text), and the article's first line found 2 times versus 1

损坏的内容在线上存在了大约 13 分钟。我们设计修复程序时,确保它不会让情况变得更糟。它先取出重写前的正文,再执行一次有问题的字符串替换。只有当结果与线上正文逐字符完全一致时,它才会使用函数形式重新构建正文并发送。如果在此期间文章发生了其他变化,比如纠错或人工编辑,修复程序就会拒绝执行并停止,避免覆盖这些修改。

算上这一次,这个命令在 24 小时内分三轮重写了 15 篇文章的结尾,每一轮都有单独的批准。15 段新文本中有 3 段包含美元符号:出问题的这一段,一段在相关文章标题中包含 ${VAR},还有一段在另一篇文章标题中包含 $ARGUMENTS 和 $0。我们用两种形式重放全部 15 次替换,只有上面这一段的结果不同。${ 和 $A 不在那张表里,$0 也不是有效的分组引用;而且对于普通字符串搜索,MDN 说 $n 无论如何都会“作为字面量替换”。另外两段能顺利通过,只是因为它们碰巧包含的是这些内容,并不是因为我们做了什么防护。

写入后的检查看错了地方

命令确实会在写入后重新读取每篇文章。在当晚提交的版本中,只要正文包含新文本,并且不再包含旧句子,就算重写成功:

return bodyMarkdown.includes(newText) && !bodyMarkdown.includes(oldLine)

这个判断问的是:“我们添加的内容在不在?”它从来没有检查其他地方是否发生了变化。正文只要是在新文本以外的任何位置受损,就仍然能通过。后续提交把它改成了完整比较:重新读回的正文,必须与我们打算发送的正文完全一致。

const verified = reread === nextBody

我们自己的记录有一处缺口,需要说明。最初运行、包含错误替换方式的代码从未提交;这个命令的首次提交已经包含修复后的替换方式,以及一条描述旧写法的注释。本文靠前位置的那行代码来自这条注释的描述,并不是我们能在 git 历史中展示的一行代码。

安全写法早已在仓库里出现过两次

修复本身,也就是把 newText 换成 () => newText,对这个代码库来说并不新鲜。两个更早的文章编辑命令已经用了这种写法:一个用于修改仍处于排期状态的文章,添加于 2026-09-28;另一个用于修复追加到已发布文章中的某个章节,添加于 2026-10-02。它们都调用 replace(from, () => to),但都没有注释解释原因。修改结尾的命令大约在第二个命令提交六小时后提交,编写时却没有沿用这个习惯。

一个只存在于两个文件里、旁边又没有写明理由的习惯,并不会自动延续到第三个文件。我们认为,这才是真正的原因:问题不在于没人知道 $ 替换模式,而在于没有任何机制确保这些知识会应用到新代码中。

同一天晚上,Agent 开始在其他地方查找同类写法,又找到了一处:一段由我们的 TypeScript 代码构造成字符串、随后由 Agent 在浏览器标签页内运行的 JavaScript 片段。其中两处 replace 调用里,有一处把变量的值作为替换参数传入,也被改成了函数形式。

规则文件里的一行要求,只有在 Agent 记得执行时才有用。而这次事故恰恰说明,知识即使已经存在于代码库里,也仍然可能没有得到应用。因此,接下来的提交做了两件事:在 Agent 遵循的编码规则里新增一条要求,传给 replace 和 replaceAll 的替换文本要写成 () => text;同时给 pnpm test 增加了一项测试。Agent 会在每次提交前运行这套测试,我们的 push 脚本也会在推送任何内容之前,针对最终代码树再运行一遍。所以,真正能拦住代码的是测试。

这项测试使用 TypeScript compiler API 解析 src 下的每一个 .ts 文件,遍历所有形如 <expression>.replace(a, b) 或 <expression>.replaceAll(a, b)、且恰好有两个参数的调用。如果第二个参数是函数或字面量,就允许通过(以下为节选,注释已翻译):

export function isSafeReplacementArgument({
    node,
}: {
    node: ts.Expression
}): boolean {
    if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) return true
    // a literal's content was chosen by whoever wrote it, including any $1 or $& in it
    return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)
}

其他任何形式,无论是标识符、包含 ${...} 的模板,还是函数调用,都会让测试失败。唯一的例外是:文件路径和参数源码文本这一精确组合,已经被列入允许名单,并附有理由。按源码文本匹配意味着,只要修改了允许名单中的参数,测试就会再次失败,直到有人重新检查新表达式并更新条目。

我们对新增这道检查的那个提交,以及今天的最新提交,分别在 src 上执行了同样的遍历。加入检查时,共有 492 个 TypeScript 文件,其中 95 个文件包含 211 处双参数 replace/replaceAll 调用:196 处传入字面量,10 处传入函数,5 处传入其他内容。这 5 处都经过检查,并被加入允许名单:两处用于脱敏的常量,固定文本中没有美元符号;一处根据标签名构造的 hashtag,标签名已经通过只允许字母、数字和连字符的检查;一处报告中的 HTML 标签,其内容完全来自我们自己代码中的固定标签;还有前面提到的修复函数,它有意把字符串传给 replace,以便复现损坏后的正文。

今天,同样的遍历找到了 607 个文件,其中 107 个文件包含 234 处调用:214 处传入字面量,15 处传入函数,以及同样的 5 处允许名单调用。自从加入这道检查后,允许名单没有增加任何条目:此后编写的每一处调用,传入的都是字面量(从 196 处增至 214 处)或函数(从 10 处增至 15 处)。

这道检查看不到什么

这道检查读取的是语法,有几种情况不在它的覆盖范围内。

它看不到字符串里的代码。上面提到的浏览器代码片段,在 TypeScript 中是一个字符串字面量,所以对编译器来说,里面的 replace 只是文本。目前我们的 src 目录中,有 4 处 replace 调用位于这类字符串内。我们手动检查过,4 处传入的都是字面量或函数;但如果再出现第 5 处,传入的是变量,也不会触发任何测试失败。

它只读取 src。其他目录不会被扫描。目前 scripts 目录里有两个 JavaScript 或 TypeScript 文件,都没有调用 replace,但如果以后发生变化,也不会有任何机制察觉。

它不检查类型。一个保存着函数的变量也会被判为不安全。对此,我们宁愿直接内联写一个箭头函数,也不愿为它增加允许名单条目。

它完全信任字面量。包含 $& 的字面量也会被接受,因为检查默认作者就是有意这样写的。对我们来说,这是合适的默认行为,但也意味着,这道检查并不能通用地保证替换文本不含特殊模式。

这个 bug 需要三个条件同时成立:替换参数位置使用了动态计算的文本;文本中有一个美元符号,后面紧跟四种特定字符之一;写入后的检查只确认新增内容是否存在。第二个条件取决于偶然,只要文章还在讨论 shell、模板和变量,它就会不断出现。这道检查消除了第一个条件,全文比较则消除了第三个条件。

如果你在自己的代码中也防范了这个问题,无论是通过 lint 规则、自定义 AST 检查、CI 中的 grep,还是单纯养成始终写 () => text 的习惯,我们都想知道你具体是怎么做的,以及它是否曾在真实代码中触发过。欢迎在下方评论区分享,也欢迎讲讲你遇到的其他情况:用户提供的文本中,一个 $ 就改变了代码的输出结果。

如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。

Original source

本文由 AI 翻译整理自 dev.to · AI,原文版权归原作者所有。

阅读英文原文
上一篇
用 Cloudflare 搭建 Agent 收信中枢
下一篇
AI 搭项目失败,先确认代码是否拉取成功