ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LLM脚本自愈闭环:从报错分析到自动修复的完整工程实践

LLM脚本自愈闭环:从报错分析到自动修复的完整工程实践 今天我想聊一个我实际搭过、也一直在用的东西脚本自我修复的 LLM Prompt。简单说就是让大语言模型在脚本报错时不只是一个报错翻译器而是能接手整个分析原因-生成补丁-重跑验证的闭环。适合谁看运维、后端、数据分析以及那些天天跟 cron、批处理、自动化脚本打交道半夜会被告警叫醒的人。先描述一个大家都不陌生的场景凌晨两点备份脚本挂了。日志里只有一行Permission denied你爬起来翻历史命令发现是上个星期改目录权限留下的坑。你花了二十分钟定位两分钟修复然后躺回去但已经醒了。这种低级错误消耗高级人力的事情才是我想做自动化的真正原因。1. 报错与修复的日常困境为什么脚本自愈值得做1.1 脚本报错的常见类型以及它们的共性我在本地跑过一阵子自动修复实验之后把日常碰到的脚本问题大致分成了三类这三类基本覆盖了八成以上的场景。一类是环境类问题。比如在 PowerShell 里直接敲npm结果告诉你无法将 npm 项识别为 cmdlet、函数、脚本文件或可运行程序的名称又比如某个.sh脚本在/bin/sh下跑但语法是 bash 专属的数组写法再比如 Windows 下 Python 脚本双击闪退其实是因为没有input()兜底。这类问题本质是脚本本身没问题但运行环境不对。二类是逻辑类问题。比如shell脚本for循环里路径拼接少了一个斜杠或者处理 CSV 时某一行字段为空导致索引越界。这类问题在开发机上可能永远不会触发一旦上了生产、喂到脏数据就崩。三类是外部依赖问题。脚本依赖的 API 返回结构变了、数据库连不上、某个工具更新后参数不兼容。这类问题最头疼因为它不在脚本内部你的代码写得再严谨也没用。这三类问题的共性是它们都能通过错误输出的信息链来定位而定位之后的修复动作往往有固定套路。比如环境类问题无非是补 PATH、换解释器、补依赖逻辑类问题是加判断、改正则、调整索引依赖类问题是改调用参数、加超时重试。这恰好是 LLM 的舒适区——给它足够上下文它能从错误信息里猜出你意图给出修复方案。1.2 为什么 LLM 适合做这件事而不是传统监控告警传统做法是监控 告警 人工。脚本挂了系统发一封邮件或一条消息然后等活人来看。这个模式的问题不在于脚本不能修而在于人力往返成本太高。更尴尬的是很多告警实际上同一类问题反复出现今天修完明天换个形式又来。LLM 驱动的修复能改变的是把人看错误信息-人理解代码-人改代码-人验证这个循环尽最大可能压缩成一个自动化管道。我并不是说要所有故障都自动改完直接上生产而是想让人只需处理最后一道要不要采纳这个补丁的确认甚至是批量确认。另一个让我坚定做这个方向的原因是LLM 有一个传统静态分析工具完全不具备的能力就是跨语言、跨工具链的常识。你的监控可能检测到exit code 2但未必知道这大概率是命令语法错误的意思LLM 看到同样的退出码结合 stderr 里的具体字段能直接联想到是因为脚本里用了set -e时命令不存在所以提前退出这种更接近人类经验的判断。1.3 自己做一个修复器之前先想清楚边界任何工具都得知道自己的边界自动修复更得知道。首先不是所有错误都能自愈。比如底层硬件故障、上游服务全面宕机这些修脚本没用甚至越修越糟糕。其次自动修复不等于自动执行。我的折中方案是修复器默认生成补丁和验证结果改动超过某个复杂度阈值比如超过 10 行 diff就自动打回给人工只有小改动才走轻量级自动执行并留下审计日志。最后要有循环上限。给 LLM 最多三次机会如果连续两轮提出不同的失败修复方案仍然过不了验证就彻底停下来进入人工通道。这能防住越改越偏的失控。把边界划清楚之后我才开始真正动手写架构。2. 一套最小可跑的自我修复闭环四个组件与一个循环2.1 从执行到修复的核心链路我自己写的这套流程核心不是 Prompt而是一个围绕 Prompt 的工程闭环。它由四段式组成执行器、诊断器、修复器、验证器。名字听起来有点重实际上就是几个函数。执行器负责做的事很朴素拿到脚本命令用子进程跑起来捕获 stdout、stderr、退出码然后全部扔给下一步。这里有一个容易被忽略的点执行器一定要把运行环境信息也带出来。用的什么解释器、什么工作目录、哪些环境变量尤其是 PATH这些是后续 LLM 判断环境类问题是否存在的关键证据。我自己实现时会在每次运行前记录env的摘要放进上下文字段。诊断器的职责是把原始错误变成结构化疑似根因。它不直接改代码而是让 LLM 先回答几个问题你最怀疑是哪一个环节出错为什么它会导致这个退出码如果要修是改环境、改参数、还是改脚本逻辑我要求这一轮只能输出分析结论不能输出修改后的代码因为混在一起很容易让 LLM 自己给自己找理由分析错误却被当成修复正确。然后是修复器。它拿到诊断器的结论、原始脚本、错误信息再结合少量历史修复记录输出一个 patch 或全新的脚本片段。为了让修复结果可控我会指定最小改动原则只修出问题的那几行禁止顺带重构。别忘了我们追求的是自愈不是惊喜。最后是验证器。把修复后的脚本重新交给执行器跑一遍看退出码是不是 0、stderr 是不是为空、有没有产生预期输出。验证标准这一步很关键不能只看退出码。有些脚本退出码是 0 但结果全是错的所以最好在 Prompt 里让修复器返回一个可验证的断言比如修复后应产生 /tmp/result.txt 且包含三行有效记录。2.2 核心循环修复历史如何喂给下一轮如果只跑一次那这系统只算单次自动修真正让它变成自我修复的是循环机制。我维护一个attempt_history列表里面记录了每一轮的轮次序号诊断结论修复 patch重跑退出码stderr 摘要验证器结论在第二轮及之后的 Prompt 中这部分历史会原样喂给 LLM并附加一句话作为任务描述修改后的脚本仍然失败请先判断之前的修复思路哪里有问题再提出新的方案。这样 LLM 不会在一个错误方向上原地打转而是能沿着试错链收敛。这里我踩过一个特别具体的坑历史信息太长之后LLM 开始敷衍。它看到前面已经有三轮分析了第四轮就只写经过分析根因不变然后再次提出几乎相同的修复。解决办法是每次只保留最近两轮历史且强制要求新一轮的分析和上一轮做对比明确说出上一轮的哪个假设被证伪了。2.3 一个最小 Python 实现的骨架我用 Python 写了一个最小实现核心逻辑不到两百行。骨架长这样先看整体结构import subprocess import json from pathlib import Path def run_script(command, env_extraNone): 执行脚本收集 stdout、stderr、exit code以及环境摘要 import os env os.environ.copy() if env_extra: env.update(env_extra) proc subprocess.run( command, shellTrue, capture_outputTrue, textTrue, envenv, timeout60 ) return { exit_code: proc.returncode, stdout: proc.stdout[-3000:], # 控制上下文长度 stderr: proc.stderr[-3000:], env_path: env.get(PATH, ), cwd: os.getcwd(), } def diagnose_with_llm(script, result, historyNone): 让 LLM 先诊断不直接给代码返回结构化 JSON prompt build_diagnose_prompt(script, result, history) response call_llm(prompt) return extract_json(response) # 拿到 {root_cause: ..., key_evidence: ..., fix_strategy: ...} def repair_with_llm(script, diagnosis, result, history): 让 LLM 基于诊断生成新脚本/patch prompt build_repair_prompt(script, diagnosis, result, history) response call_llm(prompt) return extract_script_or_patch(response) def verify_repair(original_command, new_script_path): 把修复后的脚本落地重新跑 Path(new_script_path).write_text(new_script, encodingutf-8) return run_script(fbash {new_script_path})主循环就三件事跑、诊断、修然后看验证结果。def self_heal_loop(script_path, max_rounds3): script Path(script_path).read_text(encodingutf-8) history [] for round_idx in range(1, max_rounds 1): result run_script(fbash {script_path}) if result[exit_code] 0: return {status: success, rounds: round_idx, history: history} diagnosis diagnose_with_llm(script, result, history) new_script repair_with_llm(script, diagnosis, result, history) # 写回脚本再验证 backup_path f{script_path}.bak_round{round_idx} Path(backup_path).write_text(script, encodingutf-8) Path(script_path).write_text(new_script, encodingutf-8) new_result run_script(fbash {script_path}) history.append({ round: round_idx, diagnosis: diagnosis, error_before: result[stderr][-500:], error_after: new_result[stderr][-500:], exit_after: new_result[exit_code], }) if new_result[exit_code] 0: return {status: success, rounds: round_idx, history: history} return {status: exhausted, rounds: max_rounds, history: history}你可能会质疑run_script里直接用shellTrue是不是太奔放了是的这只是本地实验骨架。实际用途中我建议换成参数化执行或容器化运行后面我会专门说安全边界。先把链路跑通确认这个思路是可行的再考虑加固。这里还要多说一句备份策略非常重要。我每一轮尝试前都会把当前脚本备份成.bak_roundN文件。你会看到这个设计在后面实战案例里发挥了大作用——有一轮我差点把好脚本改成坏脚本亏得备份链路完整才能回滚。3. Prompt 设计这是 LLM 真会修和假装会修的分水岭3.1 先讲清楚一个反直觉的结论不能用帮我修修这个问题当提示词这个项目里我试过最失败的玩法就是没做任何结构化设计直接把报错信息和脚本整个丢给 LLM说请帮我修复这段脚本。这样做的结果出现过一个极其典型的症状它修了甚至修得很漂亮但修的不是你的脚本而是它自己想象中的一个脚本。为什么会这样因为 LLM 是生成模型你给它一坨错误混杂着脚本的文本它第一反应是按最省力的路径走——看起来最像是正确的代码抄出来。你确实得到了一个语法上没毛病的脚本但可能完全没解决实际问题。用工程的话说你不约束输出的上下文格式它就会用概率魔法补全出一个平均合理的输出。修复工作最重要的是什么是绑定到具体的错误、具体的失败历史、具体的验证标准。所以你必须在 Prompt 里把这些写死同时限制它别跑偏。3.2 我的 Prompt 模板分段解释每个字段为什么必须存在先看一个诊断阶段的标准模板。整个 Prompt 的框架是角色 数据集 命令集 输出格式。你现在是一名有十年经验的全栈运维工程师。你的任务不是给出最终修复代码而是分析以下脚本为什么会执行失败。 【脚本内容】 {{{SCRIPT}}} 【执行结果】 exit code: {{{EXIT_CODE}}} stdout 最后 500 字符: {{{STDOUT}}} stderr 最后 500 字符: {{{STDERR}}} 【环境信息】 cwd: {{{CWD}}} PATH 关键部分: {{{PATH}}} 【历史尝试如果有】 {{{HISTORY}}} 【你的输出要求】 请输出一个 JSON 对象不要输出任何其他文字。 JSON 结构必须严格按照如下格式 { root_cause: 你认为最可能的根因用一句话写明不要模糊表述, evidence: 哪一段错误信息或脚本内容让你得出这个结论, verify_method: 修复后你应该运行什么命令或用什么标准来确认问题被解决, fix_strategy: 你准备怎么修但不要写具体代码只用一句话策略 }我故意在root_cause上强调不要模糊表述。LLM 默认会写脚本可能在文件读取时出了问题这种正确的废话。我要求它必须指向具体行和具体原因这会显著提高后续修复的准确率。verify_method是很容易被忽略但特别重要的一环。诊断阶段就定好验证标准能防止修复阶段改出一个语法对但逻辑错的结果。再看修复阶段的 Prompt在其他部分相同的情况下最关键的区别是输出部分以及一些额外约束在新脚本中遵守以下规则 1. 保持原有功能只修复你判断出的导致失败的根因禁止无关联的代码重构。 2. 如果问题属于环境类如 PATH 错误、解释器错误、依赖缺失优先在脚本开头通过 export 或 source 方式处理并说明理由。 3. 你必须输出一个可用 JSON 解析的对象 { patch: 修复后的完整脚本, change_summary: 你为什么做出这一处修改, risk_assessment: 高/中/低, rollback_plan: 如果这次修复失败你建议最安全的回退方式是什么 }这里我输出的不是 diff而是修复后的完整脚本。你可能觉得 diff 更优雅、更省 token。实战之后我的体会是让 LLM 输出完整脚本比输出 diff 可靠得多。diff 格式虽然在代码 review 里常用但 LLM 经常弄错行号、上下文行一错位整个补丁打不上。完整脚本的直接好处是换一个临时文件跑一次验证成本低。坏处是生成结果可能把无关代码也改了所以我才用第 1 条规则来约束。rollback_plan这个字段是我后来加的。它看起来像废话但实际触发过一次极端情况LLM 认为修复风险高主动建议如果怕改坏可以回退到上一版脚本。这时候你就能识别出这个修复最好不要自动执行的信号。3.3 few-shot 示例给一个修正方向的标本对于诊断和修复类任务few-shot 的效果非常明显。如果说上面的模板解决了LLM 用什么姿势回答问题的问题那 few-shot 解决的是LLM 什么才算一个合格回答的问题。我举一个例子是我在失败案例里试出来的。早期我在使用少量示例时发现一个问题问题的构造必须贴合真实失败场景不能随便举一个教科书式的坏脚本。我用的示例之一是脚本: 遍历所有 .log 文件并打印最后一行 for f in $(ls /var/log/*.log); do echo ---- $f ---- tail -n 1 $f done 错误信息: tail: cannot open file脚本以非零退出这个示例的精妙之处在于根因不是tail不识别而是ls /var/log/*.log在无匹配文件时返回了字面量路径然后tail尝试打开一个不存在的文件。我让模型学会看到 ls 通配符无匹配时第一反应是列出目录再判断这类真实问题诊断思路。当我给模型展示了一组诊断示例-修复示例-验证标准示例三元组之后后续回答的准确率肉眼可见提升。所以我的建议是别省那几百个 token给一两个高质量例子模型会知道你要的是分析闭环而不是一拍脑袋的修复。4. 一个完整的实战案例从报错到自动修复4.1 案例背景一段经典地挂在生产上的备份脚本为了说明这套链路实际长什么样我构造了一个案例。这个案例融合了我真实踩过的三个坑set -e导致意外退出、通配符没匹配到文件、目标目录不存在。原始脚本长这样#!/bin/bash set -euo pipefail LOG_SRC/opt/myapp/logs BACKUP_DIR/opt/myapp/backup for log_file in $LOG_SRC/*.log; do base_name$(basename $log_file) cp $log_file $BACKUP_DIR/${base_name}.$(date %Y%m%d) done echo Backup completed: $(date)这个脚本从逻辑本身来看正常环境里没问题但生产环境有一个特殊情况$LOG_SRC目录临时被清空过。当通配符匹配不到任何文件$LOG_SRC/*.log并不会变成空字符串而是变成字面量/opt/myapp/logs/*.log于是for循环会把这个不存在的路径当成一个元素执行一次然后cp报错。再加上set -e脚本直接退出。我手工修复这个问题的标准答案应该是#!/bin/bash set -euo pipefail LOG_SRC/opt/myapp/logs BACKUP_DIR/opt/myapp/backup mkdir -p $BACKUP_DIR shopt -s nullglob log_files($LOG_SRC/*.log) if [ ${#log_files[]} -eq 0 ]; then echo No log files found, exit 0 exit 0 fi for log_file in ${log_files[]}; do base_name$(basename $log_file) cp $log_file $BACKUP_DIR/${base_name}.$(date %Y%m%d) done echo Backup completed: $(date)这里修了三件事加mkdir -p处理目录缺失加shopt -s nullglob让通配符无匹配时为空加空数组判断让空目录不再进入循环主体。这三件事恰恰就是诊断器应当能从错误输出中提炼出关键证据的东西。4.2 诊断器怎么回答修复器怎么改现在我们把这段坏脚本和它的执行结果喂给 LLM。简化起见假设 stderr 是这样的cp: cannot stat /opt/myapp/logs/*.log: No such file or directory诊断阶段的 LLM 回复我按模板解析出来核心部分如下{ root_cause: for 循环中的通配符未匹配任何文件导致字面量路径传入 cp且 set -e 使脚本整体退出, evidence: cp: cannot stat /opt/myapp/logs/*.log 这一行说明 shell 没有展开通配符, verify_method: 在空目录和存在日志文件目录两种情况下分别运行脚本确认退出码均为 0, fix_strategy: 开启 nullglob 选项并在循环前置判空处理补上备份目录 mkdir -p }这里我觉得最值得尊重的一点是 LLM 对cannot stat /opt/myapp/logs/*.log的解读。它没有写成文件不存在去创建文件而是识别出这是 shell 通配符未展开问题。关键词是*被当成字面路径这在运维经验里是有对应记忆的。因为我把脚本全文给进去了它能看出set -euo pipefail的作用也知道cp遇到假路径会直接退出。修复阶段会基于这个诊断生成一个接近上面标准答案的脚本。根据我实测的经验第一轮修复有这个概率它会直接加shopt -s nullglob但漏掉set -e也没事因为空列表循环不启动但描述备份目录的mkdir -p经常被漏掉。所以验证器就要想办法验出这一点。这就是为什么我把verify_method当成诊断的一部分如果验证器真的按空目录和正常目录两种场景来跑那漏掉mkdir -p就会在备份目录不存在的情况下暴露出来。于是第二轮的 Prompt 里历史记录会显示这次修复在空目录下成功但在备份目录不存在时失败模型就更能聚焦。4.3 自动修复下来哪些地方会跟你手工修不一样自动修复和人工修复的差异在我跑通闭环后体会特别深。会有三个明显不同。第一人工看到熟悉的报错会调出历史经验直奔解决方案LLM 不一定认识你的项目规范。比如你这套脚本统一用set -euo pipefail它可能为了省事把这个严格模式直接删掉。我在实测里就遇到过它建议把set -e去掉来避免脚本退出。这确实能解决报错但代价是抹掉了后续所有隐藏错误。这是自动修复里最危险的成功。所以我后来在 Prompt 的规则里加了一条禁止通过关闭 set -e、忽略错误、追加 || true 等方式掩盖失败必须直面根因。第二LLM 往往比人工更愿意多加一个保险。比如它会在循环外用[[ -d $BACKUP_DIR ]] || mkdir -p $BACKUP_DIR这种写法正常来说没问题但如果团队风格是简洁优先你就会在 review 时发现它修出了不像你写的代码。第三它偶尔会修过头把本来没问题的日志轮转逻辑也顺手优化了一遍。我的做法是在规则里反复强调最小改动和change_summary 必须具体到行号如果改动范围明显偏大就判定为低置信度修复直接转人工。5. 防失控设计自动修复的安全边界5.1 为什么能自动执行修复不等于应该自动执行修复我见过不少人对自动修复的最大误解是它自动跑出错了负责就好省人力。但负责这件事说起来轻巧在真实生产里就是数据丢失、服务不可用、审计问责。所以我的系统改了三次边界第一次是最粗糙的修完直接跑跑通就视为成功。这在实验阶段的本地脚本上没毛病但换到任何一个稍正式的服务器上就必然闯祸。第二次我加了备份和停顿时如果发现修改过大等人工确认后再执行。第三次的进化是让 LLM 自己评估风险等级。就是说Prompt 里有risk_assessment字段修复器判断这属于低风险精确修复还是高风险方向改动。系统只对低风险改动自动应用并重跑中等及以上风险改动一律打印 diff、停下等人过一遍。这看起来是给 LLM 一个自由裁量权但实际效果很好。LLM 在明确被问到你觉得这次改动风险大不大时会比让它闷头修更谨慎。因为高风险意味着它知道自己可能在瞎猜。5.2 隔离执行别在真实的项目目录里测试未知脚本我自己的习惯是用临时目录做执行测试。每次修复后的脚本都先丢进tempfile然后在一个干净的沙箱容器里重跑或者至少在隔离的目录里跑。这个设计不是大材小用而是因为我真的遇过一个修复后的脚本把原本的/var/log/logs路径误删了虽然概率低但一旦发生后果严重。如果你没有现成容器环境那至少做到这一点在脚本前面加一段自救逻辑。比如把工作目录临时切换到一个专门建的 sandbox 目录所有输出打到沙箱里验证通过之后再实际替换真实脚本。这一小步能避免绝大部分误把开发调试脚本直接写到生产里的意外。5.3 审计与回溯每一次改动都得能倒查自动修复系统还缺最后一块拼图可追溯性。我在实现里要求每个修复轮次写一条 JSON 记录内容包括原始脚本的 hash、修复后脚本的 hash、诊断结论、LLM 回复原文、验证结果、时间戳。这样一周后如果有人问这个脚本怎么变成这样了我能直接点开历史看是哪一轮修复干的。我也强烈建议给每轮修复增加一个rollback_plan字段不只是应付 Prompt 格式而是真的在代码里实现一个restore_from_backup函数。当验证器发现修复后脚本在 24 小时内出现一台机器的问题可以靠备份快速回退。自动化跑得越快回滚链路的可靠性要求就越高。6. 进阶思路接上知识库让修复更专业6.1 脚本自带领域知识才能修的准LLM Wiki 与 RAG我前面使用的模板是通用的全栈运维工程师。但实际项目里脚本往往绑定着业务知识。举个例子一个和内部结算系统对接的脚本如果它里面依赖的接口字段命名是biz_order_id而你在代码里写成了bizOrderId报错可能是KeyError或者INVALID_ARGUMENT。通用 LLM 不知道你们内部规范只能靠猜。这就是为什么远程脚本修复在真正落地时需要给 LLM 配一个专用知识库。这个思路和LLM Wiki 知识库的实践是相通的不是让模型什么都懂而是把模型不能事先知道的东西做成可检索的上下文。比如脚本所在目录的 README 和维护记录接口文档中某几个关键字段的示例之前历次修复的故障报告、变更记录当前代码库的目录结构和命名规范。用 RAG 的方式对文档做切块索引检索出与当前脚本内容、错误字符串最相近的段落一并塞进修复阶段的 Prompt。在实际验证中这么做后修复成功率有一个明显提升尤其是碰到命名不一致、路径变更、服务迁移这类只有知道项目历史才知道怎么修的问题。6.2 检索哪些内容、怎么塞进 Prompt 不超限受限于上下文窗口你不能把整个知识库都塞给 LLM。我的做法是检索步骤从错误信息里提取关键词比如文件名、函数名、报错里出现的接口名然后把这些关键词和脚本里的变量名组合成查询语句拉取排名前几的文档片段。在塞进 Prompt 时最好用两块独立区域来表示内部参考资料和项目上下文说明。这样 LLM 就知道哪些是局限的、项目专属的事实哪些是可改变的部分。你甚至可以要求 LLM 在root_cause里标注这是基于项目资料得出的判断不是基于通用常识。这一步在长期维护时特别重要它的实际作用是当你翻看历史修复记录时能看出模型的决定是「知识库驱动」还是「常识猜测」方便你决定是否要人工介入。6.3 从脚本自愈到系统自愈同一个玩法可以复用的地方等把这套脚本级修复跑稳了你会发现可以把它平移到更大的系统里。比如在定时任务、数据管道运维、网关配置校验中整套执行-诊断-修复-验证的架构基本不用改只需要把脚本变量换成容器的启动命令、分布式任务的 YAML、或者网关的 Caddyfile。甚至 Prompt 都不需要改太多只需要把脚本改成配置/服务指令角色从运维工程师改成对应的系统专家。也就是说脚本自我修复的真正价值不在于它修好了某个备份脚本而在于它把LLM 自动运维的范式验证了一遍。当你把上述组件、Prompt 模板、防失控设计作为资产沉淀下来之后以后遇到任何一个自动化目标的失败循环都能快速做一个适配。就我个人经验而言这个方向值得投入一些时间。但要说清楚它不适合拿来处理从没见过的故障也不适合所有改动都无监督执行。它的理想场景是高频出现的半结构化故障——有明确的错误模式、有历史的修复经验、有可自动验证的成功标准。这些东西凑齐了LLM 就能从会聊天变成真干活。
返回列表