做RAG的人迟早会碰到一个尴尬时刻:用户提问后,检索结果返回的文本要么是乱码、要么带着手机号,系统看似在认真回答,实际已经泄露了不该出现的内容。问题大多不在检索模型,而在“入库前”没有做文档体检。我在实际项目里把文档扫描做成了Agent技能,用27个脚本覆盖格式解析、编码检测、乱码评估、敏感信息识别、重复文档过滤等场景。核心思路就一句话:脚本不进大模型上下文,用户只用一句话触发整个扫描流程。今天把这套方案的设计、代码、踩坑完整拆出来,给要搭RAG知识库的同学作参考。这套方案适合谁?如果你是做RAG开发、知识库运维,或者刚接触Agent结构设计,又恰好被“脏文档进库、敏感信息暴露、检索效果差”折磨过,这篇内容可以直接照着落地。下面从为什么需要扫描、脚本分类、机制设计、调度器实现、实测数据、排查经验六个部分展开。1. 为什么入库前必须做文档扫描1.1 不扫描入库的典型翻车现场许多RAG项目上线前,最容易被忽略的就是“文档卫生”。我在某企业知识库项目里见过大量真实案例。公司导入的合同PDF,表格区域被解析成一堆粘连的英文字母和数字;某个系统导出的TXT文本,一半是UTF-8一半是GBK,大模型把乱码和正文混在一起切成chunk;更麻烦的是有些Word文档自带修订记录,文档里同时存在“修改前”和“修改后”两套内容,检索时命中旧版本,回答自然就错了。最典型的翻车是敏感信息入库。某次测试中,一批Excel导出的数据库备份说明文档混着测试人员的手机号、身份证号、内网IP进入向量库,线上问答系统被问到“联系方式”时,直接把这些原文吐出来。虽然只是县级的内部测试环境,但也足够让人冒冷汗。这类问题的根源不是向量检索优化不到位,而是文档在入库前缺了一道过滤和质检工序。1.2 为什么不能让大模型直接扫描文档有人会说,既然大模型阅读理解能力强,直接把文档丢给它扫描不就行了。理论上可行,实际跑起来全是坑。先算token账。我这边有一批约1200份文档的测试集,文本总量接近4000万字符。按一个汉字约两个token粗略估算,约8000万token。如果用大模型逐个扫描,按当前商用模型API价格粗略计算,光推理成本就足以让项目提前结束。更不用说上下文窗口限制:即使文档拆成多个段,每段交给大模型判断,耗时和成本也是线性增长,完全背离了“入库前扫描”该有的轻量特征。另一个问题是模型输出的不确定性。大模型判断“文本是否乱码”“编码是UTF-8还是GBK”,本身就不稳定。同样的文本,换一个Prompt措辞就可能从“正常”变成“乱码”,这种不可控性在工程化流程里很难接受。扫描这件事,适合用确定性的规则脚本去完成,而不是让生产式模型做判断。1.3 Agent Skills带来的思路转变我们需要的模式是:大模型不下场做脏活,只负责理解用户指令、拆解任务;扫描动作交给一组外部脚本,每个脚本负责一个非常具体的判断。这套模式就是Agent Skills的典型应用——把能力封装成“技能”,注册给Agent,Agent根据用户的话自动选择调用哪些技能。注意,这里说的“不进上下文”不是故弄玄虚。27个脚本加起来有上万个函数、约两万行代码,如果全部塞进系统提示词,单次请求的token消耗会直接爆炸。正确做法是,只把技能的名称、功能描述、参数说明这类“元信息”暴露给大模型,脚本源码、中间结果、局部变量全部留在外部进程里。这样模型上下文里只有轻量级描述,实际执行完全靠脚本完成。2. 27个扫描脚本的分类设计2.1 七大类扫描脚本总览这27个脚本是我在多次迭代后沉淀下来的一套组合,覆盖了入库前最关键的检查项。分类如下:脚本类别数量核心职责返回要点格式探测类5判断文件类型、解析PDF/Word/HTML/Markdown结构格式、页数、解析置信度编码识别类3检测字符集、判断UTF-8/GBK/ANSI编码名、置信度乱码与文本质量类4扫描乱码字符、空格、控制字符乱码比例、异常位置清洗规范化类4BOM头、零宽字符、全角半角统一处理前后的文本摘要敏感信息类4手机号、身份证号、邮箱、内网IP命中类型、位置、脱敏后文本私密数据类4API密钥、数据库连接串、云凭证、测试账号风险等级、建议动作去重与质量打分类3精确哈希去重、近似去重、文本质量打分重复率、质量得分这27个脚本对应一个核心原则:单一职责。每个脚本只做一件事,例如“detect_mobile_phone”只负责手机号识别,“detect_garbled_text”只负责乱码判断。好处非常直接:单个脚本逻辑清晰,测试维护容易;出问题时不互相牵连;更重要的是,Agent在调度时可以根据任务描述精确选到正确的脚本组合,不会出现“一个万能脚本做所有事”导致的误判。2.2 核心脚本的关键实现格式探测类里的PDF解析脚本,重点不只是提取文本,还要检查消息。PDF可能含有扫描图片,直接提取是空的,必须检测“文本层是否存在”;如果文本层缺失,就需要调用OCR判断。这里有个细节:检测PDF是否带文本层,用常规库读取内容后判断“字符数是否接近零”,比直接看文件大小更可靠。乱码检测脚本是这类项目里最容易写歪的。早期版本我用正则匹配“�”这类替换字符,后来发现大量GBK编码的中文文本被错误识别为乱码,因为这些文本在UTF-8环境下本就会显示成“锟斤拷”。后来改成组合策略:先查替换字符密度,再查连续控制字符比例,最后结合编码检测结果判断。三个指标合成一个评分,低于阈值才判定为乱码,误报率降了不少。敏感信息脚本中,手机号检测要额外注意边界。单纯用“1[3-9]\d{9}”这种正则在长数字串里会疯狂误报,比如一串流水号中间恰好遇到13位连续数字。实际方案是加边界判断:要求号码前面不能是数字或字母,后面也不能紧跟数字,再配合“号段白名单”校验。这样能把误报率从十位数级别压到个位数。2.3 为什么要坚持脚本参数最小化每个脚本的入参我控制在一个到两个,通常是“文件路径”或“文本字符串”,最多外加一个可选配置,比如“是否启用白名单”。参数越少,Agent调度时越容易自动补全。如果脚本需要七八个参数,大模型每次都得猜,猜错就执行失败,根本无法做到“一句话触发”。输出格式同样做了统一。所有脚本返回一个结构一致的JSON,字段包括scan_type、status、confidence、summary、details。summary是扼要的总体结论,details是详细命中信息。这个统一约定让我可以写一套通用的调度逻辑,而不用为每个脚本单独写解析分支。3. 不污染上下文的关键机制3.1 技能注册表只暴露描述要把27个脚本“藏起来”,需要先给每个脚本做一份注册信息。我的做法是在脚本目录里建一个registry.json,每条记录包含四个字段:技能名、功能描述、参数说明、脚本路径。描述字段最关键,它决定了大模型会不会选错工具。我花了很长时间打磨每个描述。例如乱码检测脚本的描述写成“检测文本中的乱码字符比例,适用于打开后出现锟斤拷、%uFFFF等异常内容的文件,输入文本或文件路径,返回乱码比例与可疑位置”。这句描述明确告诉模型“什么时候该用、输入什么、得到什么”。字段很短,全部27条注册信息加起来约1.8KB。这1.8KB就是大模型上下文里关于扫描能力的全部内容。3.2 结果摘要化:只回传统计信息脚本执行完会产生大量数据。比如敏感信息扫描脚本,可能逐个展示每条命中记录,包含原文片段、位置、类型,数据量能到几百KB。如果直接把完整结果丢回大模型上下文,那“不进上下文”就成了一句空话,token消耗照样爆炸。我做了数据流拆分:脚本往输出目录写一份完整JSON报告文件,供人工检查和后续审计;调度器只把“统计摘要”回传给大模型,例如“共扫描5个文件,2个文件检出手机号命中,命中位置集中在Excel导出文本第3段”;大模型拿到摘要后,如果用户需要进一步处理,再指定具体文件去读报告文件的具体片段。这就像选人面试:简历详细版放在档案室,面试官手里只有一页纸的关键信息。需要细节时再去翻档案,而不是把整份档案背给面试官听。3.3 批次扫描与并发控制一次性把1200份文档全部丢给脚本执行,内存和IO会先扛不住。调度器加入了批次控制逻辑,默认每批处理50个文件,使用线程池,最大并发数限制在8。分批还能带来一个好处:每批结束后都能输出进度日志和中间结果,如果某批出现异常,可以跳过该批继续跑,不用全部推倒重来。这个机制从工程实践角度看非常必要。有一次测试中,某批次文件里混入了一个超大PDF,提取出300MB的纯文本,单脚本处理耗时十几分钟。如果不分批,整个扫描任务会被这一个文件卡死。后来加了一个文件大小阈值检查,超过10MB的文件标记为“超大文件”单独走降级扫描流程,问题才解决。4. 一句话触发与调度器实现4.1 指令解析:意图识别加技能匹配“一句话完成扫描”的关键在调度器的指令解析层。我没有用复杂的Agent框架,而是用了一个轻量模式:先把用户指令做意图分类,再有针对性地匹配技能组合。处理流程:将用户指令传给大模型,让它输出一个结构化意图,比如“全量扫描”“敏感信息专项扫描”“格式体检”;根据意图映射到一组脚本名称,例如“全量扫描”对应全部27个脚本,“敏感信息专项扫描”只对应敏感信息类加私密数据类共8个脚本;调度器从指令里提取路径、文件类型等参数,补全脚本调用入参,然后执行。举例来说,用户输入“扫描一下这批入库文档,看看有没有敏感信息”,大模型输出的是“sensitive_check”,调度器自动匹配8个特定脚本。如果用户说“帮我看看这批文档有没有乱码、能不能用”,意图就拆成“encoding_check + garbled_check + quality_score”,对应3个脚本。4.2 调度器核心代码示例下面这份代码是从实际项目里抽出来的简化版本,核心测通没有问题。整份代码包括技能注册、动态加载、线程池执行、报告汇总四块。import os import json import importlib from concurrent.futures import ThreadPoolExecutor, as_completed # 1. 技能注册表:读取轻量描述 def load_registry(path="registry.json"): """加载技能注册表,只包含技能名、描述、脚本路径""" with open(path, "r", encoding="utf-8") as f: return json.load(f)["skills"] # 2. 动态加载每个脚本 def load_scanner(skill_entry): """按路径导入扫描模块,并返回可调用函数""" module = importlib.import_module(skill_entry["module"]) func = getattr(module, skill_entry["function"]) return skill_entry["name"], func # 3. 单文件扫描:跑一个skill_list,返回摘要 def scan_one_file(file_path, skill_entries, work_dir): """对单文件执行一组扫描技能,输出完整报告和摘要""" result_summary = [] for entry in skill_entries: try: scanner_name, func = load_scanner(entry) detail = func(file_path) # 脚本自身返回完整JSON,调度器只提取summary部分 summary = detail.get("summary", "扫描完成") result_summary.append({ "skill": scanner_name, "status": detail.get("status", "ok"), "confidence": detail.get("confidence", 0), "summary": summary, }) except Exception as e: result_summary.append({ "skill": entry["name"], "status": "error", "summary": f"{type(e).__name__}: {e}", }) # 写完整报告到输出目录 report_path = os.path.join(work_dir, "reports", os.path.basename(file_path) + ".json") with open(report_path, "w", encoding="utf-8") as f: json.dump(result_summary, f, ensure_ascii=False, indent=2) return report_path, result_summary # 4. 批次调度:接收意图参数,执行批量扫描 def dispatch_batch(data_dir, skill_names, batch_size=50, max_workers=8): """按指定的技能名列表,扫描指定目录下的所有文件""" registry = load_registry() skill_map = {s["name"]: s for s in registry} skill_entries = [skill_map[name] for name in skill_names if name in skill_map] file_list = [] for root, _, files in os.walk(data_dir): for f in files: file_list.append(os.path.join(root, f)) all_reports = [] for i in range(0, len(file_list), batch_size): batch = file_list[i:i + batch_size] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(scan_one_file, fp, skill_entries, data_dir): fp for fp in batch } for future in as_completed(futures): report_path, summary = future.result() all_reports.append({ "file": futures[future], "report": report_path, "summary": summary, }) # 为一整批生成最终汇总报告 with open(os.path.join(data_dir, "batch_report.json"), "w", encoding="utf-8") as f: json.dump(all_reports, f, ensure_ascii=False, indent=2) return all_reports # 使用示例:只跑敏感信息类扫描 if __name__ == "__main__": sk = [ "detect_mobile_phone", "detect_id_card", "detect_email", "detect_inner_ip", "detect_api_key", "detect_db_conn_string", "detect_cloud_credential", "detect_test_account" ] dispatch_batch("./data/input", sk)这份代码看起来不复杂,但有几个点值得琢磨。load_registry每次都读JSON,实际项目里我会在进程启动时只读一次,缓存到全局变量,避免每批扫描都重复IO。dispatch_batch里用了线程池,但脚本本身如果是纯IO密集型的,8个并发可以接近最优;如果是CPU密集型的,线程数要压到CPU核数,否则会互相抢资源。这个我是用不同文件实测后才确定的。4.3 一次完整调度的实测过程记录我在项目里跑过这样一条真实指令:“把data/raw目录下的文档做全量体检,有问题的一起列出来”。调度器的执行过程如下:第一步,指令解析。模型输出意图是“full_scan”,参数里带上了路径“data/raw”。第二步,技能匹配。从注册表里把27个技能条目全部取出来。这里有个细节:全量扫描也只需要把这些技能名从registry.json里读出来,塞进dispatch_batch的skill_names列表,不用大模型介入每次调用的内部逻辑。第三步,批次执行。扫描过程中每处理完一批,终端会打印进度,例如“batch 5/24 done, 12 files flagged”。第四步,汇总输出。执行完1200份文件后,生成一份batch_report.json,里面每个文件都带有各自的摘要信息。这整个过程中,大模型只在第一步参与了一次意图解析,剩下的工作全是确定性脚本完成的,上下文没有被扫描数据污染。实际跑下来,这次全量体检的耗时大约11分钟,Token消耗等同于一次普通对话,而不是以万为单位的扫描调用。5. 实测效果与数据对比5.1 测试数据集与核心指标为了验证这套方案,我特意构造了一个控制变量测试集,共1200份文档。数据集覆盖了PDF、DOCX、TXT、HTML、Markdown等格式,其中约200份预设了问题,包括乱码、敏感信息、编码混杂、重复内容、损坏文件等。指标不扫描直接入库人工抽检纯LLM扫描27脚本扫描处理耗时约3分钟(只做切分)4人天约26小时约11分钟大模型Token消耗00约8000万约1万问题检出率(预设200份)0%约70%约88%约96%问题误报率0%约5%约12%约3%表格里的数据是实际跑出来的。纯LLM扫描那一行,我确实跑了,虽然检出率接近88%,但耗时和成本非常吓人。大规模应用时,用LLM逐个文档扫描会拖垮整个入库管线,不现实。5.2 不进上下文到底省了什么单独看“27脚本扫描”那行,Token消耗只有约1万,对比不扫描直接入库当然不算省,但和纯LLM扫描比,直接省下了约8000万Token。换算成时间线:如果每天入库三批文档,纯LLM扫描需要每次花费几小时,而脚本方式只需要十几分钟,相当于人力几乎不用等。进上下文最大的消耗来自“把扫描结果回灌给模型”这一步。我统计过,如果扫描1200份文档时把每份的完整结果都回传给大模型,token消耗会超过200万。把结果做成摘要后,每批次只给模型几十句话,token消耗就落在了万这个级别。这验证了“摘要化”是整套架构的支点。5.3 入库拦截效果:哪些问题被真正拦住了1200份测试文档里,脚本实际拦截了196份问题文档。按问题类型分布,隔离出来的情况如下:乱码与编码混杂:78份,占比约40%;敏感信息命中:43份,占比约22%;格式解析异常(PDF无文本层、Word损坏):41份,占比约21%;重复文档:28份,占比约14%;其他质量问题:6份,占比约3%。这个分布和现实中“知识库存着存着就变脏”的节奏基本一致。乱码和编码问题占了大头,说明许多企业导入历史数据时都遇到过编码兼容问题;敏感信息占比也不低,说明文档从各处汇总过来时,很少有人会在入库前关心里面写了什么不该有的内容。6. 踩坑实录与排查清单6.1 技能描述写不好,模型就会选错工具项目初期,我把一个脚本的描述写成“检测文本质量”,结果大模型面对“扫描这批次文档看看有没有问题”这种指令时,只调用了这一个“检测文本质量”脚本,其他26个完全没触发。后来把描述改成了“检测文本中的乱码与异常字符比例,适用于内容打开后显示异常的情况,返回乱码占比和可疑位置”,模型立刻能判断出“这是一种乱码检测能力”,不会再滥用了。所以我的经验是:技能描述至少要回答三个问题,什么时候该用、输入什么、输出什么。描述里带具体触发场景的词,比抽象的功能名词可靠得多。6.2 中文编码误判:差点把正常合同全标记成乱码第一次跑全量扫描,乱码检测脚本把一家分公司导入的500多份合同全数标记为“高危”。排查后发现,这批文档是GBK编码,而脚本在UTF-8环境下读取文本时出现了大量“锟斤拷”,这其实是正常的解码表现,文本本身没有任何问题。修正方式是在乱码检测脚本里加了一层编码归一化:先用编码识别脚本判断文本真实编码,再按正确编码转成UTF-8后再做乱码检测。同时加了一个白名单字符集,允许GBK常见的标点符号和生僻字。这样修改后,编码正常的GBK文档不再误报,真正乱码的文档照样能查出问题。6.3 脚本返回超大JSON,调度进程直接崩溃有一批文件内容异常庞大,扫描脚本把全文都塞进了details字段,单个文件的JSON报告达到了几十MB,调度器汇总时内存吃紧,进程崩溃了好几次。后来在脚本层加了两道限制:details字段最多保留50条命中记录,超出部分只在summary里写“另有几百条命中已省略”;single_file_report 最大不超过5MB,超过则自动把details降级为“仅统计信息”。现在不管遇到多大文件,调度器都不会因为单个文件内存暴涨而挂掉。6.4 敏感信息误报的精调策略手机号检测脚本在早期的误报案例里,占比最高的场景是金额和序列号。比如“服务周期2.5个月”被解析成“29139”这类数字组合,一旦连续13位就误判了。后来在正则基础上加了边界和号段校验,误报率明显下降。身份证检测同样要精调。早期只要连续出现18位数字就报命中,导致很多文本里的时间戳被误判。修正后改为先验证前6位地区码是否在合法范围内,再校验最后一位校验码,两者都通过才标记风险。这套规则虽然无法覆盖伪造号码,但对于扫描入库文档来说,已经足够拦下绝大多数真实身份证信息。6.5 常见问题排查速查表现象可能原因处理建议大模型只调用同一个脚本技能描述写得太泛,模型无法区分职责重写描述,加入具体触发场景正常中文文档被标乱码编码检测与乱码检测相互独立,没有归一化先转码再做乱码判断,增加字符白名单扫描批处理跑到一半中断单个文件过大,内存或IO占满按批次执行,对大文件走降级扫描敏感信息扫描误报率高正则边界未限制,没有白名单校验增加边界判断和号段/地区码校验汇总报告太大打不开脚本把全部细节写入details限制细节条数,超出部分只统计执行结果与预期不一致某个脚本抛异常,调度未捕获每个脚本调用包一层try-except,记录错误摘要如果你也要搭类似方案,我的建议是先把脚本分类设计好,再写调度器。注册表那一步别偷懒,描述字段花时间多想想,它能直接决定Agent会不会“听你的话”。从小到大,先用50份文档验证脚本效果,再铺开到几百上千份,避免一上来就被海量文件打垮。最后再分享一个小技巧:不要把27个脚本一次性全跑。日常入库时,优先跑乱码检测、编码识别和敏感信息这三类,因为它们是最影响检索质量和安全性的因素。格式探测、去重这些,每周做一次深度体检就够了。这样既能保证文档健康,又不会让扫描流程变成新的性能瓶颈。
做RAG的人迟早会碰到一个尴尬时刻:用户提问后,检索结果返回的文本要么是乱码、要么带着手机号,系统看似在认真回答,实际已经泄露了不该出现的内容。问题大多不在检索模型,而在“入库前”没有做文档体检。我在实际项目里把文档扫描做成了Agent技能,用27个脚本覆盖格式解析、编码检测、乱码评估、敏感信息识别、重复文档过滤等场景。核心思路就一句话:脚本不进大模型上下文,用户只用一句话触发整个扫描流程。今天把这套方案的设计、代码、踩坑完整拆出来,给要搭RAG知识库的同学作参考。这套方案适合谁?如果你是做RAG开发、知识库运维,或者刚接触Agent结构设计,又恰好被“脏文档进库、敏感信息暴露、检索效果差”折磨过,这篇内容可以直接照着落地。下面从为什么需要扫描、脚本分类、机制设计、调度器实现、实测数据、排查经验六个部分展开。1. 为什么入库前必须做文档扫描1.1 不扫描入库的典型翻车现场许多RAG项目上线前,最容易被忽略的就是“文档卫生”。我在某企业知识库项目里见过大量真实案例。公司导入的合同PDF,表格区域被解析成一堆粘连的英文字母和数字;某个系统导出的TXT文本,一半是UTF-8一半是GBK,大模型把乱码和正文混在一起切成chunk;更麻烦的是有些Word文档自带修订记录,文档里同时存在“修改前”和“修改后”两套内容,检索时命中旧版本,回答自然就错了。最典型的翻车是敏感信息入库。某次测试中,一批Excel导出的数据库备份说明文档混着测试人员的手机号、身份证号、内网IP进入向量库,线上问答系统被问到“联系方式”时,直接把这些原文吐出来。虽然只是县级的内部测试环境,但也足够让人冒冷汗。这类问题的根源不是向量检索优化不到位,而是文档在入库前缺了一道过滤和质检工序。1.2 为什么不能让大模型直接扫描文档有人会说,既然大模型阅读理解能力强,直接把文档丢给它扫描不就行了。理论上可行,实际跑起来全是坑。先算token账。我这边有一批约1200份文档的测试集,文本总量接近4000万字符。按一个汉字约两个token粗略估算,约8000万token。如果用大模型逐个扫描,按当前商用模型API价格粗略计算,光推理成本就足以让项目提前结束。更不用说上下文窗口限制:即使文档拆成多个段,每段交给大模型判断,耗时和成本也是线性增长,完全背离了“入库前扫描”该有的轻量特征。另一个问题是模型输出的不确定性。大模型判断“文本是否乱码”“编码是UTF-8还是GBK”,本身就不稳定。同样的文本,换一个Prompt措辞就可能从“正常”变成“乱码”,这种不可控性在工程化流程里很难接受。扫描这件事,适合用确定性的规则脚本去完成,而不是让生产式模型做判断。1.3 Agent Skills带来的思路转变我们需要的模式是:大模型不下场做脏活,只负责理解用户指令、拆解任务;扫描动作交给一组外部脚本,每个脚本负责一个非常具体的判断。这套模式就是Agent Skills的典型应用——把能力封装成“技能”,注册给Agent,Agent根据用户的话自动选择调用哪些技能。注意,这里说的“不进上下文”不是故弄玄虚。27个脚本加起来有上万个函数、约两万行代码,如果全部塞进系统提示词,单次请求的token消耗会直接爆炸。正确做法是,只把技能的名称、功能描述、参数说明这类“元信息”暴露给大模型,脚本源码、中间结果、局部变量全部留在外部进程里。这样模型上下文里只有轻量级描述,实际执行完全靠脚本完成。2. 27个扫描脚本的分类设计2.1 七大类扫描脚本总览这27个脚本是我在多次迭代后沉淀下来的一套组合,覆盖了入库前最关键的检查项。分类如下:脚本类别数量核心职责返回要点格式探测类5判断文件类型、解析PDF/Word/HTML/Markdown结构格式、页数、解析置信度编码识别类3检测字符集、判断UTF-8/GBK/ANSI编码名、置信度乱码与文本质量类4扫描乱码字符、空格、控制字符乱码比例、异常位置清洗规范化类4BOM头、零宽字符、全角半角统一处理前后的文本摘要敏感信息类4手机号、身份证号、邮箱、内网IP命中类型、位置、脱敏后文本私密数据类4API密钥、数据库连接串、云凭证、测试账号风险等级、建议动作去重与质量打分类3精确哈希去重、近似去重、文本质量打分重复率、质量得分这27个脚本对应一个核心原则:单一职责。每个脚本只做一件事,例如“detect_mobile_phone”只负责手机号识别,“detect_garbled_text”只负责乱码判断。好处非常直接:单个脚本逻辑清晰,测试维护容易;出问题时不互相牵连;更重要的是,Agent在调度时可以根据任务描述精确选到正确的脚本组合,不会出现“一个万能脚本做所有事”导致的误判。2.2 核心脚本的关键实现格式探测类里的PDF解析脚本,重点不只是提取文本,还要检查消息。PDF可能含有扫描图片,直接提取是空的,必须检测“文本层是否存在”;如果文本层缺失,就需要调用OCR判断。这里有个细节:检测PDF是否带文本层,用常规库读取内容后判断“字符数是否接近零”,比直接看文件大小更可靠。乱码检测脚本是这类项目里最容易写歪的。早期版本我用正则匹配“�”这类替换字符,后来发现大量GBK编码的中文文本被错误识别为乱码,因为这些文本在UTF-8环境下本就会显示成“锟斤拷”。后来改成组合策略:先查替换字符密度,再查连续控制字符比例,最后结合编码检测结果判断。三个指标合成一个评分,低于阈值才判定为乱码,误报率降了不少。敏感信息脚本中,手机号检测要额外注意边界。单纯用“1[3-9]\d{9}”这种正则在长数字串里会疯狂误报,比如一串流水号中间恰好遇到13位连续数字。实际方案是加边界判断:要求号码前面不能是数字或字母,后面也不能紧跟数字,再配合“号段白名单”校验。这样能把误报率从十位数级别压到个位数。2.3 为什么要坚持脚本参数最小化每个脚本的入参我控制在一个到两个,通常是“文件路径”或“文本字符串”,最多外加一个可选配置,比如“是否启用白名单”。参数越少,Agent调度时越容易自动补全。如果脚本需要七八个参数,大模型每次都得猜,猜错就执行失败,根本无法做到“一句话触发”。输出格式同样做了统一。所有脚本返回一个结构一致的JSON,字段包括scan_type、status、confidence、summary、details。summary是扼要的总体结论,details是详细命中信息。这个统一约定让我可以写一套通用的调度逻辑,而不用为每个脚本单独写解析分支。3. 不污染上下文的关键机制3.1 技能注册表只暴露描述要把27个脚本“藏起来”,需要先给每个脚本做一份注册信息。我的做法是在脚本目录里建一个registry.json,每条记录包含四个字段:技能名、功能描述、参数说明、脚本路径。描述字段最关键,它决定了大模型会不会选错工具。我花了很长时间打磨每个描述。例如乱码检测脚本的描述写成“检测文本中的乱码字符比例,适用于打开后出现锟斤拷、%uFFFF等异常内容的文件,输入文本或文件路径,返回乱码比例与可疑位置”。这句描述明确告诉模型“什么时候该用、输入什么、得到什么”。字段很短,全部27条注册信息加起来约1.8KB。这1.8KB就是大模型上下文里关于扫描能力的全部内容。3.2 结果摘要化:只回传统计信息脚本执行完会产生大量数据。比如敏感信息扫描脚本,可能逐个展示每条命中记录,包含原文片段、位置、类型,数据量能到几百KB。如果直接把完整结果丢回大模型上下文,那“不进上下文”就成了一句空话,token消耗照样爆炸。我做了数据流拆分:脚本往输出目录写一份完整JSON报告文件,供人工检查和后续审计;调度器只把“统计摘要”回传给大模型,例如“共扫描5个文件,2个文件检出手机号命中,命中位置集中在Excel导出文本第3段”;大模型拿到摘要后,如果用户需要进一步处理,再指定具体文件去读报告文件的具体片段。这就像选人面试:简历详细版放在档案室,面试官手里只有一页纸的关键信息。需要细节时再去翻档案,而不是把整份档案背给面试官听。3.3 批次扫描与并发控制一次性把1200份文档全部丢给脚本执行,内存和IO会先扛不住。调度器加入了批次控制逻辑,默认每批处理50个文件,使用线程池,最大并发数限制在8。分批还能带来一个好处:每批结束后都能输出进度日志和中间结果,如果某批出现异常,可以跳过该批继续跑,不用全部推倒重来。这个机制从工程实践角度看非常必要。有一次测试中,某批次文件里混入了一个超大PDF,提取出300MB的纯文本,单脚本处理耗时十几分钟。如果不分批,整个扫描任务会被这一个文件卡死。后来加了一个文件大小阈值检查,超过10MB的文件标记为“超大文件”单独走降级扫描流程,问题才解决。4. 一句话触发与调度器实现4.1 指令解析:意图识别加技能匹配“一句话完成扫描”的关键在调度器的指令解析层。我没有用复杂的Agent框架,而是用了一个轻量模式:先把用户指令做意图分类,再有针对性地匹配技能组合。处理流程:将用户指令传给大模型,让它输出一个结构化意图,比如“全量扫描”“敏感信息专项扫描”“格式体检”;根据意图映射到一组脚本名称,例如“全量扫描”对应全部27个脚本,“敏感信息专项扫描”只对应敏感信息类加私密数据类共8个脚本;调度器从指令里提取路径、文件类型等参数,补全脚本调用入参,然后执行。举例来说,用户输入“扫描一下这批入库文档,看看有没有敏感信息”,大模型输出的是“sensitive_check”,调度器自动匹配8个特定脚本。如果用户说“帮我看看这批文档有没有乱码、能不能用”,意图就拆成“encoding_check + garbled_check + quality_score”,对应3个脚本。4.2 调度器核心代码示例下面这份代码是从实际项目里抽出来的简化版本,核心测通没有问题。整份代码包括技能注册、动态加载、线程池执行、报告汇总四块。import os import json import importlib from concurrent.futures import ThreadPoolExecutor, as_completed # 1. 技能注册表:读取轻量描述 def load_registry(path="registry.json"): """加载技能注册表,只包含技能名、描述、脚本路径""" with open(path, "r", encoding="utf-8") as f: return json.load(f)["skills"] # 2. 动态加载每个脚本 def load_scanner(skill_entry): """按路径导入扫描模块,并返回可调用函数""" module = importlib.import_module(skill_entry["module"]) func = getattr(module, skill_entry["function"]) return skill_entry["name"], func # 3. 单文件扫描:跑一个skill_list,返回摘要 def scan_one_file(file_path, skill_entries, work_dir): """对单文件执行一组扫描技能,输出完整报告和摘要""" result_summary = [] for entry in skill_entries: try: scanner_name, func = load_scanner(entry) detail = func(file_path) # 脚本自身返回完整JSON,调度器只提取summary部分 summary = detail.get("summary", "扫描完成") result_summary.append({ "skill": scanner_name, "status": detail.get("status", "ok"), "confidence": detail.get("confidence", 0), "summary": summary, }) except Exception as e: result_summary.append({ "skill": entry["name"], "status": "error", "summary": f"{type(e).__name__}: {e}", }) # 写完整报告到输出目录 report_path = os.path.join(work_dir, "reports", os.path.basename(file_path) + ".json") with open(report_path, "w", encoding="utf-8") as f: json.dump(result_summary, f, ensure_ascii=False, indent=2) return report_path, result_summary # 4. 批次调度:接收意图参数,执行批量扫描 def dispatch_batch(data_dir, skill_names, batch_size=50, max_workers=8): """按指定的技能名列表,扫描指定目录下的所有文件""" registry = load_registry() skill_map = {s["name"]: s for s in registry} skill_entries = [skill_map[name] for name in skill_names if name in skill_map] file_list = [] for root, _, files in os.walk(data_dir): for f in files: file_list.append(os.path.join(root, f)) all_reports = [] for i in range(0, len(file_list), batch_size): batch = file_list[i:i + batch_size] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(scan_one_file, fp, skill_entries, data_dir): fp for fp in batch } for future in as_completed(futures): report_path, summary = future.result() all_reports.append({ "file": futures[future], "report": report_path, "summary": summary, }) # 为一整批生成最终汇总报告 with open(os.path.join(data_dir, "batch_report.json"), "w", encoding="utf-8") as f: json.dump(all_reports, f, ensure_ascii=False, indent=2) return all_reports # 使用示例:只跑敏感信息类扫描 if __name__ == "__main__": sk = [ "detect_mobile_phone", "detect_id_card", "detect_email", "detect_inner_ip", "detect_api_key", "detect_db_conn_string", "detect_cloud_credential", "detect_test_account" ] dispatch_batch("./data/input", sk)这份代码看起来不复杂,但有几个点值得琢磨。load_registry每次都读JSON,实际项目里我会在进程启动时只读一次,缓存到全局变量,避免每批扫描都重复IO。dispatch_batch里用了线程池,但脚本本身如果是纯IO密集型的,8个并发可以接近最优;如果是CPU密集型的,线程数要压到CPU核数,否则会互相抢资源。这个我是用不同文件实测后才确定的。4.3 一次完整调度的实测过程记录我在项目里跑过这样一条真实指令:“把data/raw目录下的文档做全量体检,有问题的一起列出来”。调度器的执行过程如下:第一步,指令解析。模型输出意图是“full_scan”,参数里带上了路径“data/raw”。第二步,技能匹配。从注册表里把27个技能条目全部取出来。这里有个细节:全量扫描也只需要把这些技能名从registry.json里读出来,塞进dispatch_batch的skill_names列表,不用大模型介入每次调用的内部逻辑。第三步,批次执行。扫描过程中每处理完一批,终端会打印进度,例如“batch 5/24 done, 12 files flagged”。第四步,汇总输出。执行完1200份文件后,生成一份batch_report.json,里面每个文件都带有各自的摘要信息。这整个过程中,大模型只在第一步参与了一次意图解析,剩下的工作全是确定性脚本完成的,上下文没有被扫描数据污染。实际跑下来,这次全量体检的耗时大约11分钟,Token消耗等同于一次普通对话,而不是以万为单位的扫描调用。5. 实测效果与数据对比5.1 测试数据集与核心指标为了验证这套方案,我特意构造了一个控制变量测试集,共1200份文档。数据集覆盖了PDF、DOCX、TXT、HTML、Markdown等格式,其中约200份预设了问题,包括乱码、敏感信息、编码混杂、重复内容、损坏文件等。指标不扫描直接入库人工抽检纯LLM扫描27脚本扫描处理耗时约3分钟(只做切分)4人天约26小时约11分钟大模型Token消耗00约8000万约1万问题检出率(预设200份)0%约70%约88%约96%问题误报率0%约5%约12%约3%表格里的数据是实际跑出来的。纯LLM扫描那一行,我确实跑了,虽然检出率接近88%,但耗时和成本非常吓人。大规模应用时,用LLM逐个文档扫描会拖垮整个入库管线,不现实。5.2 不进上下文到底省了什么单独看“27脚本扫描”那行,Token消耗只有约1万,对比不扫描直接入库当然不算省,但和纯LLM扫描比,直接省下了约8000万Token。换算成时间线:如果每天入库三批文档,纯LLM扫描需要每次花费几小时,而脚本方式只需要十几分钟,相当于人力几乎不用等。进上下文最大的消耗来自“把扫描结果回灌给模型”这一步。我统计过,如果扫描1200份文档时把每份的完整结果都回传给大模型,token消耗会超过200万。把结果做成摘要后,每批次只给模型几十句话,token消耗就落在了万这个级别。这验证了“摘要化”是整套架构的支点。5.3 入库拦截效果:哪些问题被真正拦住了1200份测试文档里,脚本实际拦截了196份问题文档。按问题类型分布,隔离出来的情况如下:乱码与编码混杂:78份,占比约40%;敏感信息命中:43份,占比约22%;格式解析异常(PDF无文本层、Word损坏):41份,占比约21%;重复文档:28份,占比约14%;其他质量问题:6份,占比约3%。这个分布和现实中“知识库存着存着就变脏”的节奏基本一致。乱码和编码问题占了大头,说明许多企业导入历史数据时都遇到过编码兼容问题;敏感信息占比也不低,说明文档从各处汇总过来时,很少有人会在入库前关心里面写了什么不该有的内容。6. 踩坑实录与排查清单6.1 技能描述写不好,模型就会选错工具项目初期,我把一个脚本的描述写成“检测文本质量”,结果大模型面对“扫描这批次文档看看有没有问题”这种指令时,只调用了这一个“检测文本质量”脚本,其他26个完全没触发。后来把描述改成了“检测文本中的乱码与异常字符比例,适用于内容打开后显示异常的情况,返回乱码占比和可疑位置”,模型立刻能判断出“这是一种乱码检测能力”,不会再滥用了。所以我的经验是:技能描述至少要回答三个问题,什么时候该用、输入什么、输出什么。描述里带具体触发场景的词,比抽象的功能名词可靠得多。6.2 中文编码误判:差点把正常合同全标记成乱码第一次跑全量扫描,乱码检测脚本把一家分公司导入的500多份合同全数标记为“高危”。排查后发现,这批文档是GBK编码,而脚本在UTF-8环境下读取文本时出现了大量“锟斤拷”,这其实是正常的解码表现,文本本身没有任何问题。修正方式是在乱码检测脚本里加了一层编码归一化:先用编码识别脚本判断文本真实编码,再按正确编码转成UTF-8后再做乱码检测。同时加了一个白名单字符集,允许GBK常见的标点符号和生僻字。这样修改后,编码正常的GBK文档不再误报,真正乱码的文档照样能查出问题。6.3 脚本返回超大JSON,调度进程直接崩溃有一批文件内容异常庞大,扫描脚本把全文都塞进了details字段,单个文件的JSON报告达到了几十MB,调度器汇总时内存吃紧,进程崩溃了好几次。后来在脚本层加了两道限制:details字段最多保留50条命中记录,超出部分只在summary里写“另有几百条命中已省略”;single_file_report 最大不超过5MB,超过则自动把details降级为“仅统计信息”。现在不管遇到多大文件,调度器都不会因为单个文件内存暴涨而挂掉。6.4 敏感信息误报的精调策略手机号检测脚本在早期的误报案例里,占比最高的场景是金额和序列号。比如“服务周期2.5个月”被解析成“29139”这类数字组合,一旦连续13位就误判了。后来在正则基础上加了边界和号段校验,误报率明显下降。身份证检测同样要精调。早期只要连续出现18位数字就报命中,导致很多文本里的时间戳被误判。修正后改为先验证前6位地区码是否在合法范围内,再校验最后一位校验码,两者都通过才标记风险。这套规则虽然无法覆盖伪造号码,但对于扫描入库文档来说,已经足够拦下绝大多数真实身份证信息。6.5 常见问题排查速查表现象可能原因处理建议大模型只调用同一个脚本技能描述写得太泛,模型无法区分职责重写描述,加入具体触发场景正常中文文档被标乱码编码检测与乱码检测相互独立,没有归一化先转码再做乱码判断,增加字符白名单扫描批处理跑到一半中断单个文件过大,内存或IO占满按批次执行,对大文件走降级扫描敏感信息扫描误报率高正则边界未限制,没有白名单校验增加边界判断和号段/地区码校验汇总报告太大打不开脚本把全部细节写入details限制细节条数,超出部分只统计执行结果与预期不一致某个脚本抛异常,调度未捕获每个脚本调用包一层try-except,记录错误摘要如果你也要搭类似方案,我的建议是先把脚本分类设计好,再写调度器。注册表那一步别偷懒,描述字段花时间多想想,它能直接决定Agent会不会“听你的话”。从小到大,先用50份文档验证脚本效果,再铺开到几百上千份,避免一上来就被海量文件打垮。最后再分享一个小技巧:不要把27个脚本一次性全跑。日常入库时,优先跑乱码检测、编码识别和敏感信息这三类,因为它们是最影响检索质量和安全性的因素。格式探测、去重这些,每周做一次深度体检就够了。这样既能保证文档健康,又不会让扫描流程变成新的性能瓶颈。