把AI Agent从一个能跑的Demo做成可以被团队持续维护的工程,门槛比大多数人想的高。我这边最近半年一直在折腾一件事:让Agent代码像普通后端代码一样,每次提交都自动构建、自动测试、给出能不能合入的结论。这篇记录的是我在AI Agent Harness工程中落地持续集成(CI)的完整实践——包括流水线怎么分阶段、Agent代码构建时到底要构建什么、测试怎么绕过LLM的随机性做稳定断言,以及踩过的若干坑。适合正在做AI Agent产品化、或者准备把Agent项目从个人玩具变成团队代码库的读者。
1. 为什么Agent工程需要一套独立的CI方案
1.1 Agent代码的真实构成
在动手设计CI之前,我先在项目里做了一次“代码盘点”,搞清楚一份Agent代码到底由哪些东西组成。以我们现在维护的一个支持工具调用的编码Agent为例:
- Prompt模板。包括System Prompt、各个工具的Instruction、以及若干Few-shot示例。这部分是行为的主要控制器,往往以.yaml或.md文件形式维护。
- 工具定义与注册逻辑。每个工具都有名称、描述、参数Schema,以及对应的Python函数实现。
- 编排逻辑。Agent的主循环,包括如何调用LLM、解析返回、决定下一步动作、管理上下文长度和记忆。
- 配置。模型名、温度、超时时间、重试次数、日志级别。
- Harness运行时配置。Agent跑在什么样的环境里,有哪些权限、哪些环境变量、哪些目录可写。
这五部分如果放在一个普通工程里,可能分别对应了“配置中心+业务代码+流程编排”,但在Agent工程里它们耦合度非常高。Prompt改了可能比代码改了影响还大;工具描述写差了,LLM压根不会调用它。所以CI要管的不只是“代码能不能编译过”,而是“这套提示词+代码+配置的组合,行为上还能不能达到预期”。
1.2 Harness在工程里的真实角色
很多人第一次接触Harness这个词会困惑:它和Agent本身是什么关系?我的理解是,Harness是Agent的“运行载体”和“约束框架”,它把LLM客户端、工具执行、上下文管理、权限边界这些东西打包成一个可控的运行环境。开源社区的DeepSeek Harness、Codex Harness都算这个方向的产物,侧重点不同,但本质都是给Agent提供一个标准化、“可被工程化”的运行时。
工程上为什么需要Harness?因为一个裸的Agent循环一旦接上真实工具,就会出现大量“环境副作用”:文件写到哪了、进程权限多大、外部API从哪个网络出口访问,这些如果没有收口,测试和上线就无从谈起。Harness最大的价值,是把这些不确定因素变成可配置、可观测的东西。而CI要做的事,恰恰是在一个全新的、干净的环境里把这个Harness搭起来,再跑Agent——如果这个过程能自动化且稳定,说明整个Agent工程真的是“可交付”的。
1.3 传统CI经验为什么不能直接照搬
我刚接手Agent项目时,第一反应是“不就是加一条流水线吗,后端那套搬过来就行”。结果跑了不到两周就发现几个根本性的差异。
第一,输出不确定。普通接口的测试是输入一串数据,断言输出结构;Agent的“输出”是自然语言和一连串工具调用,同样的输入跑两次,结果大概率不一致。断言写死了就是天天红。
第二,外部依赖太厚。单元测试可以Mock掉LLM API,但只要跑集成测试,真实模型调用就要花真金白银,而且延迟从几百毫秒到十几秒不等,流水线动不动跑十几分钟,开发体验很差。
第三,行为回归很难量化。代码改没改坏,看编译和单测就能知道;Prompt改没改坏,没有一个像“编译错误”一样明确的信号,得靠用例集,靠人工判定输出质量。
这些差异决定了Agent项目的CI不能照抄传统方案,得针对“非确定性”和“高价外部依赖”做专门设计。后面我所有的流水线阶段和测试策略,都是围绕这两点展开的。
2. CI流水线整体设计与阶段拆解
2.1 六个阶段的设计逻辑
我最后落地的流水线分了六个阶段,每个阶段解决一类问题,越靠前跑得越快、越便宜,让问题尽早暴露:
- 静态检查与格式校验
- Prompt与配置的Schema校验
- 单元测试(全Mock,不碰外部服务)
- Harness镜像构建
- 集成测试(真实模型,小样本)
- 产物归档与发布
为什么这么分?核心逻辑是“快失败、低成本”。静态检查和Schema校验几秒钟就能出结果,改动有低级错误,没必要浪费时间构建镜像和调模型。单元测试走Mock,能在几十秒内覆盖大部分编排逻辑。真正的模型调用放在集成测试阶段,并且控制在很小的样本集上,保住成本和速度。
以GitLab CI为例,stage的声明大致长这样:
stages: - lint - schema-check - unit-test - build - integration-test - release每个stage里可以并行跑多个job,比如schema-check和lint可以同时跑,unit-test按模块并行,build只构建一次镜像给integration-test复用。这套结构用GitHub Actions、Jenkins都能等价实现,关键不是工具,而是阶段划分的层次。
2.2 工具链选型与Runner环境
工具选型我踩过一个坑:一开始用了GitHub Actions的公共Runner,跑集成测试时发现网络出口不稳定,调用模型API的失败率特别高,而且构建缓存每次都被清理,导致管道越来越慢。后来我把Runner切换成自托管的Kubernetes Runner,一方面网络环境可控,另一方面可以用PVC做层缓存,pip、apt、镜像层都能命中。
自托管Runner要注意隔离。Agent集成测试会真的执行代码、操作文件系统,如果环境被污染,测试结果没有参考意义。我给Runner的每个Job都配置了隔离的命名空间和临时卷,保证每次构建从同一个基线开始。CI里看起来是“每次重新构建”,但因为有缓存,实际上并不会慢到不可接受。
2.3 依赖锁定与环境可重现
Agent工程的依赖比普通项目还要“敏感”一些。普通项目锁Python包就够了;Agent项目除了pip依赖,还有Prompt文件、模型版本、Harness运行时的系统依赖,任何一环漂移,行为都可能变化。
我做了三件事来锁环境:
- requirements.txt锁定Python包版本,Debian基础镜像固定到具体tag,不用latest。
- Prompt模板和配置作为独立资源包,带上内容哈希,任何修改都会在流水线里产生新的构建产物。
- 模型本身用model版本+temperature等关键参数生成“模型指纹”,写进测试报告里,后面排查行为回归时能快速对齐。
这样做的收益在后面对接“某个提交流水线红了但本地跑是好的”这类问题时非常明显:只要比对镜像哈希和模型指纹,就能快速定位是不是环境不一致。
3. Agent代码的自动化构建实操
3.1 构建的不只是“代码”
Agent工程的构建产物,我称为“Agent包”。它不只是Python代码的集合,而是把代码、Prompt资源、工具描述、运行配置打成一个自包含的、可在目标环境里直接启动的产物。
我在CI里的build job主要做了这几件事:
- 安装Python依赖,固定版本。
- 编译并校验Prompt模板:把YAML解析成字典,检查必填字段、变量占位符是否都有对应值。
- 生成工具注册表:扫描工具定义代码,和配置里的工具列表比对,防止“配了但没实现,或者实现了但没注册”。
- 打包,生成带内容哈希的镜像tag。
第三步是最容易漏的。Agent项目里,工具的描述文本由维护者写,工具的Schema可能由代码生成,二者经常不同步。我在构建阶段写了一个校验脚本,遍历所有工具的元数据,检查名称、参数、描述是否完整,并和一份万能的注册清单比对。这个脚本帮我们抓出过不少“低级的连线错误”。
3.2 Harness镜像构建与缓存优化
构建Agent的运行时镜像时,我选择以python:3.11-slim为基础,而不是用一个带GUI或大量预装软件的大镜像。原因是:Agent执行任务需要的系统工具应该在运行时显式注入,而不是全都塞进基础镜像,否则镜像体积大、构建慢、攻击面也大。
Dockerfile的核心思路是分层缓存:
FROM python:3.11-slim WORKDIR /agent # 先拷贝依赖清单,利用Docker层缓存 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 再拷贝工程代码与资源 COPY agent/ ./agent/ COPY prompts/ ./prompts/ COPY config/ ./config/ # 非root用户运行 RUN useradd -m agentuser USER agentuser ENTRYPOINT ["python", "-m", "agent.harness"]这里有个细节:把requirements.txt和代码分开COPY,是Docker缓存的关键。只要依赖没变,前面的pip层就不会重跑;而代码层重新构建只影响后面几层,能省下大量的流水线时间。
3.3 用内容哈希管理Prompt和配置
Prompt和配置我是当作“一等公民”来管理的。系统Prompt的每一处用词变化,都值得一次代码评审,所以在构建产物里我会打一个“内容指纹”。具体做法是:构建时用sha256计算所有prompt和yaml配置的哈希,写进镜像的版本标签里。
这段逻辑很简单:
HASH=$(find prompts config -type f -exec sha256sum {} \; | sha256sum | cut -d' ' -f1) TAG="agent-${CI_COMMIT_SHORT_SHA}-${HASH}"好处特别实在:当测试报告里出现“prompt_hash=abc123,但代码版本是def456”这种信息时,很快就能判断某个行为变化到底是不是Prompt引起的。也可以精确地知道“同一个代码版本、不同Prompt指纹”的行为差异,这对做Prompt回归分析特别有用。
4. Agent测试策略:从单测到端到端
4.1 单元测试怎么Mock掉LLM
Agent的单测目标不是测模型智商,而是测编排逻辑:给定一个LLM返回结果,Agent的下一步动作对不对?工具调用顺序对不对?异常分支有没有兜住?
所以我在单测里用了两层Mock。第一层,Mock掉LLM客户端,让它返回预置的响应序列;第二层,Mock掉工具执行副作用,文件操作落到临时目录,网络调用改成假的。
以pytest为例,一个典型的测例是“Agent在收到工具结果后,是否会在上下文中追加结果并再次调用LLM”:
def test_agent_appends_tool_result_and_reinvokes(harness, fake_llm): fake_llm.set_responses([ {"content": "", "tool_calls": [{"name": "search_kb", "args": {"query": "CI"}}]}, {"content": "基于检索结果,回复如下……", "tool_calls": []}, ]) harness.run("请基于知识库回答:CI在Agent工程里怎么落地?") assert fake_llm.call_count == 2 last_messages = fake_llm.context_messages() assert any("工具结果" in m for m in last_messages)这种测试的好处是快、稳定、完全不花钱,能抓住Agent主循环里90%的逻辑错误。我把这类测试放在单元测试阶段,几十秒就能跑完全套。
4.2 集成测试:验证Harness环境本身
单元测试Mock掉了所有外部依赖,所以它验证不了“Harness环境真的能跑通工具调用”这件事。这块由集成测试来补:在构建出来的镜像里,跑真实代码路径,允许部分外部服务以测试桩形式存在。
我在集成测试阶段会执行几类场景:真实文件读写、命令执行(在沙箱里)、以及向一个受控的模型API发小批量请求。重点不是判断模型回答好不好,而是确认“环境可运行”“工具链完整”“权限边界正确”这三点。
比如权限边界,我会用一个用例断言Agent不能写它不应该碰的目录。这个场景不需要模型真的聪明,只要一个固定Prompt触发文件写操作,然后检查落盘位置是否符合Harness的权限约束。这类“安全性质感”的测试特别值得做,因为一旦Agent被外部输入诱导去做危险操作,权限边界就是最后的防线。
4.3 E2E评测:用固定用例集守住行为底线
最后一道测试是E2E,也是最有Agent特色的。E2E的目标是回答一个模糊问题:这次改动之后,Agent的整体行为表现还是预期的吗?
我的做法是维护一份“验收用例集”,每个用例包含:任务描述、期望达成的结果要点或必须遵守的约束、执行超时。CI跑E2E时,用真实模型在构建出来的Harness镜像里跑这组用例,然后把结果按维度打分:是否完成任务、是否调用了预期工具、是否输出格式正确。这里不需要Agent输出跟历史完全一致,重点是不允许出现“行为退化”,比如原来会调知识库工具,改完Prompt后完全不调了。
这一阶段的成本控制很重要。我的验收用例集目前只保持15到20条覆盖核心场景的用例,每条用例限制在2分钟以内,整个阶段控制在30分钟左右。它不可能替掉人工评测,但足以作为合并门禁:如果核心用例大面积挂了,说明这个改动动摇了Agent的基线能力,需要人工介入。
4.4 质量门禁怎么设
流水线阶段都跑完,得有个明确的“能不能合入”的结论。我在Merge Request的规则里设了三道门:
- 静态检查和Schema校验必须全绿,否则直接block;
- 单测覆盖率不低于设定阈值,变化率告警;
- E2E核心用例通过率不能低于90%,且关键用例(比如涉及支付、权限、数据删除)一个都不能挂。
门禁不是越严越好。E2E因为涉及真实模型,天然有波动,把阈值设在100%会让团队疲于重试,甚至为了过门禁去删用例,等于自欺欺人。我最后把核心用例分成P0和P1两个级别,P0要求全绿,P1允许有少量抖动并开放重跑通道,这样既守住了底线,又不至于让流水线变成形式主义。
5. 常见问题与排查技巧实录
5.1 测试不稳定:LLM输出不一致怎么办
这是Agent测试里最头疼的问题,没有之一。同一个用例,上午跑过,下午跑就红了,改了什么代码吗?没有,纯粹是模型抽风。
我的处理分三层。第一层,尽量降低模型温度,核心场景直接用temperature=0,减少随机性。第二层,调整断言方式,不比对输出文本,而是比对“行为签名”——调用了什么工具、生成了什么结构、状态有没有变。第三层,对确实无法消除的波动,引入“允许重试且自动记录”机制:首次失败自动重跑一次,如果两次结果不一致,说明用例本身对模型输入敏感,标记为flaky并进入人工评估。
最忌讳的就是看到E2E红了直接点重跑,也不看日志。重跑是手段,不是目的;状态稳定后,我会去翻上一轮失败时模型的完整调用链路,看是哪一步行为偏移导致结果变了。
5.2 构建产物与本地环境不一致
“我本地跑好好的,CI里挂了”这种问题,在Agent项目里非常常见。原因通常是三处不一致:一是本地Python版本和镜像不一致,二是本地环境变量或代理设置比CI多,三是Harness依赖的系统库在镜像里没装全。
排查路径我固定为:对着CI日志里的镜像tag和模型指纹,在本地启动一个一模一样的容器,手动执行同样的用例。如果容器里能复现问题,说明是构建环节有遗漏;如果复现不了,多数是测试数据或网络环境差异。把“在哪个环境复现”作为第一类问题,能少走很多弯路。
5.3 流水线跑一次太慢、太贵
Agent集成测试一次可能要跑几十分钟,真实模型调用还烧钱。我后来做了两个优化。一个是在流水线前面增加“变更影响分析”:如果改动只涉及文档或配置注释,直接跳过E2E阶段;如果改动涉及Prompt或编排逻辑,才触发完整E2E。
另一个优化是把E2E用例按“改动相关性”分组,代码路径分析后只跑相关分组,而不是每次都全量裸跑。这两个策略落地后,非关键提交通常5分钟以内就能出创新,全量验证只在合入主干或发版本时跑,CI的成本立刻降了一个量级。
5.4 Mock与真实LLM偏差过大
因为Mock环境跑得飞快,集成测试又贵又慢,团队很容易重心倾斜到Mock测试上。但Mock有个致命问题:Mock器返回的完美Tool Call格式,真实模型不一定会给。经常出现的情况是,Mock测试全绿,一上真实模型就发现模型生成的参数不是标准Json,或者多次返回了不在工具列表里的调用名。
我的应对策略是:在集成测试阶段专门放一组“脏数据”用例,测试真实模型在Harness里的容错路径。比如故意让工具返回格式错误的结果,检查Agent是否正常处理;让模型返回工具调用列表里不存在的工具名,检查编排层是否过滤掉且不崩溃。这类测试写起来别扭,但攒多了之后,Mock和真实的差距会越缩越小。
5.5 几个提高CI收益的实用建议
最后分享几条我认为性价比很高的做法。一是测试报告要沉淀,每次E2E的原始输出、打分结果、模型指纹都归档,后面做Prompt回归分析时数据就是底气。二是把CI能不能快速给出“结论”当成第一目标,流水线宁可少跑用例,也要保证不稳定时能给出明确的失败点,不要让测试箱变成黑盒。三是CI不是上线之后才补的东西,越早接入,越早逼着团队把Harness环境做标准,后面的坑会少很多。
在Agent工程里,持续集成解决的表面问题是“自动化构建和测试”,但更深的收益是逼着你把环境、依赖、行为基线都变成可管理的资产。这套东西落地后,团队对每一次Agent改动的信心会明显不一样——至少改Prompt的时候,不用再靠祈祷了。