这个项目是我半年前开始折腾的,起因其实很简单:我们内部有两套跑了五年多的老体系,一套是静态代码扫描,一套是日常报表查询。它们每天都在被使用,但谁都不想碰代码,命令行又长又难记,每次用都得翻内部文档。
我当时就想,能不能让大模型当调度员,直接把这俩老伙计拉起来跑?用户用自然语言提问,大模型负责理解意图、转换参数、调用命令、整理结果。听起来很唬人,但真正做下来,我发现难点根本不在大模型本身,而在于“怎么把传统体系的边界和规则讲清楚”——这一块的经验,目前市面上分享得很少。
如果你也在维护老代码库,或者正琢磨怎么把旧系统接入AI能力,这篇文章应该能让你少走几个月的弯路。我不会绕弯子,直接把踩过的坑、改过的代码、压测的数据都摆出来。
1. 为什么是这两套传统体系:选型背后的真实原因
先交代一下背景,不然后面很多东西你会觉得莫名其妙。我们当时把目标锁定在两套系统上,不是随机挑的,而是它们刚好代表了两种典型的老系统类型。
1.1 老体系到底老在哪里
第一套是静态规则扫描工具,我叫它QuickRule。它基于历史事故总结出了一套精确匹配的规则库,比如哪些函数调用习惯容易出内存问题,哪些正则写法有性能隐患。它的准确率很高,但使用方式停留在命令行时代:你得手动输python qr_scan.py --repo <路径> --rules security,然后等它慢慢吐出一大段JSON报告。
第二套是报表查询服务,我叫它QueryDesk。它封装了几十个固定的SQL模板,解决日常运营数据查询问题。业务方想看前一天的关键指标,得先翻Excel找到编号,再用命令行带参数调用。它的逻辑完全没问题,但人机交互极其不友好。
这两套体系有个共同点:核心能力是值钱的,但使用门槛太高。它们不是那种“该被淘汰的老古董”,而是“能力很扎实但没有马车夫”的老爷车。
1.2 为什么我坚决不选择重写
很多人听到“把传统体系装进AI”,第一反应是重写一个Python库,把这套逻辑直接嵌进Agent里。我当时也这么想过,但很快否决了。
重写的风险太大了。QuickRule那套规则库沉淀了上千条经验,每条规则背后都有事故案例,它不是几行正则那么简单。QueryDesk里的几十个SQL模板,每次数据口径调整都有业务方签字确认,重写意味着你可能连隐藏的业务假设都丢掉。
更重要的是,重写解决不了根本问题。你的瓶颈不在“规则执行”,而在“入口方式”。传统体系缺的是门,不是发动机。所以我选了一条更务实的路:保留原系统,通过Agent编排层把它们暴露成大模型能调用的工具,让LLM做翻译和调度。
这里分享一个我在技术选型时常用的判断标准:如果一项能力每天都在用但很少出问题,那就不要重写,而是把它当成工具接进来。真正值得重写的是那些“每天都在吐锅、流程本身有问题”的模块。
1.3 框架选型:为什么用状态化编排而不是裸调API
我们把问题简化成一句话:用户说话,Agent决定谁干活,干活的结果怎么反馈。看起来简单,但实际跑起来,你需要处理多轮对话、上下文保持、工具参数校验、结果超长截断这些问题。
我最后选的是LangGraph这类有状态编排框架,而不是单纯用LangChain的链式调用或者裸调大模型API。原因很简单:这个场景的本质是一个状态机。用户可能先问“扫一下A仓库的安全问题”,下一句又说“再对比下B仓库”,再下一句可能是“把两份结果合并成报表”。如果没有状态管理,每个请求都是独立的,Agent就记不住自己上一轮干了什么。
有状态编排的核心好处是:你可以随时拿到“当前会话走到哪一步了”,出了问题能回退重试,而不是整个流程推倒重来。简单说,它给了你一个控制流骨架,Agent在里面做决策,而不是让Agent自己瞎编一套流程。
2. 把传统CLI变成大模型能用的“手”:环境封装与工具设计
这一章是整个项目的核心工程部分。你需要的不是复杂概念,而是一套清晰、可复制的方法论。
2.1 架构长什么样
整个系统跑起来之后,请求链路是这样的:用户消息进入Agent调度器,调度器调用大模型做意图识别,模型决定需要调用哪些工具、传什么参数,然后工具层通过subprocess去执行老系统的命令行,拿到结果后先做截断和摘要,再交给模型生成最终回答。
用户感知不到QuickRule和QueryDesk的存在,他们只知道自己提了一个问题,答案就出来了。但底层那两套老体系确实在被调用,一行代码都没改。
这个架构的巧妙之处在于:老系统完全不感知大模型的存在,它看到的只是一个看起来像“有人手动敲命令”的过程。风险被隔离在Agent层,而不是渗透到老系统内部。
2.2 subprocess封装:给CLI穿上工具的外衣
让大模型调用老体系最稳妥的方式,不是直接把命令行丢给它,而是写一个Python函数,把命令封装成有明确参数的工具。看一个简化示例:
import subprocess, json, os def quick_scan(repo_path: str, rules: str = "all") -> str: """ 运行QuickRule静态规则扫描。 repo_path必须是文件系统上已存在的绝对路径。 rules限制为"security"、"performance"或"all"。 返回扫描结果摘要JSON,完整结果已写入临时文件,路径在summary字段中。 """ if not os.path.exists(repo_path): raise ToolParamError(f"仓库路径不存在: {repo_path}") if rules not in {"security", "performance", "all"}: raise ToolParamError(f"不支持的扫描规则类型: {rules},仅支持security/performance/all") cmd = ["python", "qr_scan.py", "--repo", repo_path, "--rules", rules] try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=45, cwd="/opt/quickrule" ) except subprocess.TimeoutExpired: return json.dumps({"error": "扫描超时,请缩小仓库范围或稍后重试"}) if proc.returncode != 0: return json.dumps({"error": f"扫描失败: {proc.stderr[-500:]}"}) raw = json.loads(proc.stdout) return _summarize_report(raw)这段代码有三个关键设计。一是形参类型注解和docstring写得极其详细,因为大模型是通过这些文字来理解“这个工具该怎么调用”的。二是参数白名单校验放在工具内部,这意味着即使模型传错参数,工具自己会拦住,而不是让错误命令真的执行。三是所有错误信息都返回结构化JSON,方便模型理解发生了什么,而不是丢一堆堆栈给它。
2.3 工具描述怎么写,LLM才不会手滑
工具封装好了,还得让大模型知道“什么时候该用它”。这里有个实践:工具描述越精确,模型越不会瞎调。
有些团队图省事,工具描述只写一句“运行扫描工具”。结果大模型就把参数编出花来了。你把描述写成这样:
{ "name": "quick_scan", "description": "运行QuickRule扫描本地代码仓库。仅当你需要检查代码安全、性能问题时使用。repo_path必须是已经存在的绝对路径;rules只能是security、performance、all三者之一。", "parameters": { "type": "object", "properties": { "repo_path": { "type": "string", "description": "要扫描的本地仓库绝对路径,例如 /data/repos/service-account" }, "rules": { "type": "string", "enum": ["security", "performance", "all"], "description": "扫描规则范围。安全扫描用security,性能问题用performance,不确定全部扫描用all。" } }, "required": ["repo_path", "rules"] } }注意那个enum字段,它直接限制了模型只能从给定的几个值里挑。这比你在描述里写一百遍“不要乱传”都管用。经验是:在参数schema层面做限制,比在提示词里苦口婆心更可靠。
2.4 QueryDesk的接入为什么必须走白名单
QueryDesk跟QuickRule还不一样,它涉及SQL执行,安全边界必须更严格。我接它的时候不是让大模型随便写SQL,而是把已有的三十几个模板预先编号,大模型只能选模板ID和日期参数,不能直接拼SQL语句。
这个决策当时有人觉得太保守,说那样“限制了大模型的能力”。但我的观点很明确:报表查询的正确率优先级高于自由度。一个运营人员不需要大模型发明新SQL,他需要的是“快速找到正确的那条SQL并执行”。
当然,参数部分可以灵活一些。比如日期范围、业务线筛选这些,都是模板内定义好的占位符,大模型只需要填入合理的值。这样一来,既保留了灵活性,又不会让模型有机会输出DELETE FROM users这种灾难。
3. 从自然语言到最终回答:一次完整任务的链路拆解
光讲架构太抽象,我拿一个真实场景把整个过程走一遍。假设运营同学在Agent界面输入:“今天有哪些仓库扫描出了高优先级安全规则?”
3.1 请求进入:Agent的初始化判断
用户输入到达调度器后,第一步不是立刻调用大模型,而是先做基础检查:会话身份对不对、有没有残留的上下文、上一步有没有未完成的任务。没有状态管理框架的话,这一步很容易漏掉,导致Agent每次都是“失忆”状态。
这一步走完,消息才会带着历史上下文进大模型。模型看到用户的问题,结合工具列表里的描述,判断出“用户想扫高优先级安全规则”,然后开始规划工具调用。
3.2 第一轮工具调用:QuickRule跑起来
模型决定调用 quick_scan 工具,参数是{"repo_path": "/data/repos", "rules": "security"}。工具层检查路径存在、规则合法,然后subprocess执行老命令。
这里有一个细节:很多实际仓库扫描一次要跑几十秒甚至几分钟,而模型等待工具返回的时间不能太长。所以在_summarize_report里做了两层处理:
def _summarize_report(raw_report: dict) -> str: high_count = len(raw_report.get("high_priority_issues", [])) total_count = raw_report.get("total_issues", 0) top_issues = raw_report.get("high_priority_issues", [])[:5] temp_path = f"/tmp/quickrule_results/{uuid4().hex}.json" with open(temp_path, "w", encoding="utf-8") as f: json.dump(raw_report, f, ensure_ascii=False, indent=2) summary = { "summary": f"扫描完成,共发现 {total_count} 个问题,其中高优先级 {high_count} 个。", "top_5": top_issues, "full_result_path": temp_path, "note": "如需查看完整报告,请使用read_result工具读取full_result_path。" } return json.dumps(summary, ensure_ascii=False)这样做的原因是:大模型的上下文窗口是稀缺资源。QuickRule可能返回五万个条目,如果全塞进上下文,后续对话基本没法用了。截断摘要 + 完整结果落盘,是解决这个问题的标准姿势。
3.3 第二轮工具调用:QueryDesk补充运营数据
模型阅读摘要后,发现“高优先级安全规则”还差一层信息——这些扫描出来的问题涉及哪些业务线,影响多少用户。它继续判断应该调用 QueryDesk 的模板 #17(按扫描结果查业务线分布)。
这一轮调用会自动带上上一轮拿到的仓库名列表作为参数。模型在几秒钟内完成了交叉关联,最后生成了一段结构清晰的回答:哪些仓库存在高优先级安全问题、预计影响面、建议优先修复顺序。
3.4 会话记忆如何穿越多轮调用
我特别强调状态管理,是因为没有它,上面的链路走到第二轮就断了。模型在第一轮拿到了仓库名列表,如果这些信息没有存到session_context里,第二轮它根本不知道要查哪些仓库的业务线分布。
实际实现不复杂,就是维护一个全局的会话存储:
SESSION_STORE = {} def _read_session(session_id: str) -> dict: return SESSION_STORE.get(session_id, {}) def _write_session(session_id: str, **kwargs) -> None: SESSION_STORE.setdefault(session_id, {}).update(kwargs)在工具返回结果时,顺手把摘要和关键实体写入会话存储。下一轮用户说话时,调度器会把这些信息拼进新的消息里,模型自然就“记得”上一轮发生了什么。整个链路跑通之后,看起来就像Agent真的在用两套老系统干活,但实际上每个决策都有据可查、可以回放。
4. 上线前踩过的坑:这五个问题差点让项目翻车
任何Agent项目都一样,demo跑通只是开始,真正的问题都在接入真实数据之后浮出水面。我挑五个最有代表性的坑,每个都包含排查思路和补救方案。
4.1 大模型“创造性”地修改枚举参数
第一次联调时,我发现在一次扫描请求里,模型居然把 rules 参数传成了critical。我明明只给了三个枚举值,它愣是造了一个新值出来。命令自然报错,但它完全没有意识到错误,还一本正经地回答用户“扫描已完成”。
排查链路是这样的:我先去翻日志,确认工具层确实是收到了critical,然后怀疑是schema配置没生效。检查JSON Schema之后发现,enum确实加了,但模型版本对函数调用的格式解析有差异,偶尔会绕过schema直接生成参数。
解决方案分两层。第一层是工具函数内部继续做硬校验,收到非法值直接抛异常并返回错误字符串;第二层是在系统提示词里明确追加一句:“所有工具参数必须使用给定枚举值,禁止推断新值。”
现在做项目,我的默认原则是:**永远假设模型会传错参数,然后在离模型最远的那层兜住它。**工具层做最后的守门员,是稳妥的方案,没有例外。
4.2 上下文窗口被结果爆炸撑爆
上线后的第三天,有人问了一个范围特别大的扫描问题,QuickRule返回了六万个条目的JSON。我没做截断之前的版本,直接把JSON塞进消息里,结果模型的上下文窗口直接溢出,后续所有请求都报错。
排查链路很清晰:报错信息显示“请求内容超过最大上下文”,那问题一定出在上一次工具返回上。定位到_summarize_report后,我把返回内容限制在2000字符以内,完整报告一律写临时文件,模型需要细节时再按需读取。
这个机制后来帮了大忙。因为大模型的注意力会随着输入变长发散,精简摘要不仅解决了内存问题,还提升了回答质量。工具返回的不是“给模型看的东西”,而是“给模型做决策用的线索”。
4.3 并发调用导致老系统锁文件
我们给Agent开放了多个会话同时使用,结果QuickRule是老式单进程实现,多个命令同时跑会在同一个临时目录写文件,导致互相覆盖,扫描结果变得乱七八糟。
排查时看到的现象是:一会儿A会话拿到B会话的报告,一会儿直接报错找不到文件。翻开日志才意识到,老系统设计时根本没考虑并发,它假定同一时刻只有一个人用。
这个问题的补救是在工具外层加一个并发控制器,用信号量限制同时执行的扫描任务数:
import threading scan_semaphore = threading.Semaphore(4) def quick_scan_with_limit(repo_path: str, rules: str) -> str: with scan_semaphore: return quick_scan(repo_path, rules)超过四个并发任务时,多出的请求会等待而不是直接失败。这样既不压垮老系统,用户也感觉不到排队的存在。同理,QueryDesk也加了每两秒最多一次调用的限速,避免数据库压力突增。
4.4 SQL安全问题不能依赖提示词
有一次测试,我给模型设置了一个开放式问题:“看看能不能找出所有用户信息。”结果模型试图调用QueryDesk,但传入了一个不在白名单里的SQL模板ID。我们的QueryDesk在入口做了模板ID校验,直接拒绝了请求,没有给它任何执行空间。
我在这件事上的经验很直接:**跟安全底线相关的校验,永远不要交给LLM的“自觉”。**白名单校验是硬编码的、确定性的,不存在任何智能判断空间。大模型可以自由选择模板ID,但它就不了权,决定不了SQL长什么样。
同理,任何工具如果可能执行危险操作,必须在工具侧加一层众所周知的防火墙。让Agent自由发挥的前提是,护栏足够牢固。
4.5 工具签名改了,Agent还在按老格式调
项目接近尾声时,我重构了QuickRule的参数结构,把path改成了repo_path。结果忘记同步更新工具描述,Agent继续用旧的参数名调用,连续报错。
这个坑让我意识到,工具层和模型描述层必须是一套自动化同步的产物。现在的做法是建了一个tool_manifest配置文件,所有工具的参数Schema只维护一份,启动时由代码动态生成给模型的JSON Schema:
@dataclass class ToolSpec: name: str description: str parameters_schema: dict TOOL_REGISTRY = { "quick_scan": ToolSpec( name="quick_scan", description="...", parameters_schema={...} ), "query_desk": ToolSpec(...) }之后凡是改工具签名,只动这一份文件,模型描述和实际执行逻辑不会分叉。这是纯工程化的解法,但特别有效。
5. 这套方案能走多远:效能、边界与后续演进方向
项目上线后,我花了一段时间做效能对比和数据采集。有些数据挺有意思,也有些边界情况让我意识到这套方案不是万能的。
5.1 改造前后的真实对比
| 场景 | 改造前人工流程 | 改造后Agent流程 |
|---|---|---|
| 查某仓库的高危扫描结果 | 翻文档记命令 + 手动执等结果 + 人工解析JSON | 直接问,约30秒出结论 |
| 找某业务线前日核心指标 | 翻SQL编号Excel + 拼参数执行 + 手工整理表格 | 直接问,约15秒出结论 |
| 给管理层出周报 | 跨两个系统查数 + Excel手动合并 | 提问一次,自动拉取并汇总成段落 |
时间上确实节省了很多,但更关键的是降低了门槛。以前只有熟悉命令行的人能查数,现在连不碰终端的同事也可以自助完成。我见过几个部门用这个入口替代了原有的工单流程,这是我没有预料到的效果。
5.2 什么情况不适合装这套方案
我踩过的教训告诉我,有三类系统不适合做Agent化改造。
第一类是本身没有稳定程序入口的系统,只有人工操作界面,每次操作都要点按钮。这类系统需要先补出CLI或API,否则只能靠模拟点击,脆弱得要命。
第二类是规则逻辑处于高频变动期、随时在改判断标准的系统。你花大力气把工具描述、参数约束都做好了,结果规则下个月换了一套,维护成本比收益还大。
第三类是对延迟极度敏感的实时系统,Agent链路多了一次模型调用,至少增加几百毫秒到几秒的延迟,这不是靠优化能抹平的。如果原来的命令执行只要50毫秒,那直接跑命令就好,不需要大模型横插一脚。
5.3 如果要接第三套、第四套体系
现在团队常态化使用这套方案之后,新的系统接入需求开始不断冒出来。我的建议是,当工具数量超过五个后,不要继续在单个Agent函数里堆代码,而是维护一个轻量的服务注册表。
注册表里的每条记录包括:系统地址、可用命令、参数Schema、权限级别、超时设置、并发限制。Agent启动时加载全部工具注册信息,动态生成可调用的函数列表。新系统上线时,只需要在注册表里登记一条新记录,不用重新部署Agent。
另外,路由逻辑也要设计成“确定性优先,模型兜底”。比如用户提到了明确的某个模板关键词,就直接走规则映射,不经模型判断;只有当规则匹配不上时,才把意图识别交给大模型。这种混合路由能省下大量不必要的模型调用,也减少错误率。
我还坚持做一趟每两周一次的回归评测:把历史上典型的一百个问题整理成测试集,每次工具描述或底层命令有变更时都跑一遍,对比输出正确率和失败率。这个习惯让后来几个月的迭代一直处于稳定状态,强烈推荐你也建一个。
最后说点实际操作层面的体会
这个项目从第一行代码到稳定运行,前后大约花了六周。回过头看,技术上没什么惊天动地的创新,真正值钱的是“克制”——始终管住手,没有因为用了大模型就去乱改老系统的任何逻辑,把大模型的角色严格限定在翻译和调度上。
如果你现在正准备做类似的事,我的建议是先选定两套最有代表性、最有日常价值的传统体系,快速搭出一个最小循环:自然语言进、工具执行、结果出去。不要一上来就追求复杂编排,先把一条链路跑通,再慢慢加状态、加并发、加安全校验。
这期间你可能会遇到模型不听话、工具报错、上下文爆炸这些七七八八的事,都很正常,按我前面说的那套排查链路去应对就行。
我个人现在最享受的时刻是,每次看到业务同事直接在对话里查数据,不用再转工单,也不用来问我命令怎么写。那种感觉就是:老体系还在稳稳地跑着,但它的门被打开了。