本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

前几节做完的 Agent 跑在自己的终端里。要让它干活,需要打开命令行、输入任务、等待返回。这套流程对个人够用,对团队不行:同事不会为了请人看一个 bug 而先学会启动一个脚本。

NPC 处理的是交付方式。它把同一个 Agent 搬到仓库里,用事件触发,用评论交付。游戏里的 NPC 不主动行动,玩家走上去对话它才给任务;仓库里的 NPC 同理,没人 @ 它的时候它不存在,有人 @ 就接活,干完把结果发回同一条评论。

两种形态的差别

维度 本地 Agent 仓库 NPC
谁在跑 本机 云端容器
谁触发 仅本人 仓库里任何有权限的人
何时跑 手动启动 事件触发
结果去哪 终端 自动回写评论

关键变化在最后一行。本地跑的时候,操作者是唯一的读者,输出打在屏幕上就完事;挂到仓库之后没有屏幕可看,评论就是结果本身。这个差别决定了入口脚本的写法。

三样配置

把 Agent 变成仓库 NPC,需要准备三样东西。

角色设定定义这个 NPC 是谁:名字、头像、口吻、擅长什么、遵循什么约束。这段文本在每次任务时拼进 prompt,相当于一段 system prompt 前缀。

事件绑定定义谁触发、跑什么。平台需要知道评论里出现哪个名字对应执行哪条流水线,因此要建立角色名到镜像的映射,并约定没有点名时的默认角色。

入口脚本是容器的启动点。它从平台注入的环境变量里读出评论内容、仓库地址、issue 编号,组装成任务描述,驱动 Agent 执行,最后把结果发回原评论。

三样东西里入口脚本最容易写错。它运行在非交互容器里,没有终端,没人看得见它的输出。任何只打印不发送的实现,效果都等于什么都没做。

一次触发经过的环节是这样:评论 @ 角色,平台匹配事件,拉起镜像,入口脚本读环境变量并组装 prompt,Agent 干活,回写评论。中间的 Agent 部分与前几节完全一致,改变的是输入来源和输出目的地。

另一种触发源:日志

评论触发适合”人发现问题之后求助”的场景。还有一类问题不需要人发现,系统自己就会报错,这时触发源是日志。

HyperTicket 的 log_ai_detector 是一个可以直接对照的实现。它把触发、定位、回写三件事拆成独立模块,串成一条常驻链路:

1
2
3
4
5
日志轮询(inode + offset,支持滚动)
→ 解析 ERROR / FATAL 行,取出 level、文件、函数、行号、消息
→ codegraph 检索相关源码位置
→ 组装上下文交给模型分析
→ 生成 Markdown 报告,落盘并推送通知

触发源不同,后面几步的设计就跟着变。评论内容是自然语言,模型可以从中读出意图;日志是一行结构化文本,必须先解析出错误级别、源文件、函数名和行号,模型才有定位的起点。这也是这类系统把日志解析单独抽成一个模块的原因。

从日志行到源码位置

日志能说明”哪里炸了”,说不清”为什么炸”。从前者走到后者,靠的是代码索引。

log_ai_detector 把 codegraph 的调用封装成可配置命令模板,索引、检索、增量更新三类命令都写在环境变量里,上游命令行变化时不必改代码。检索用的查询词就是日志里解析出来的函数名、源文件名和错误消息片段,一次失败可以换关键词再试,最多取三段结果。

索引不可用时有一条降级路径:按日志里的源文件名在项目里定位文件,取报错行上下各二十行作为上下文。这条路径精度低,但比没有强,也让整个系统不依赖单一外部工具。

索引还需要跟着代码变。仓库里有一个 git hook:检测到 feat 类型的提交,就增量更新 codegraph 索引,其他类型的提交跳过,更新失败不阻断提交。没有这一步,索引很快会和代码脱节,定位质量随提交次数下降。

拼一个够用的上下文

定位日志问题需要的材料不止一段源码。这个实现把四类信息拼在一起送进模型:

  • 触发事件的原始字段:时间、级别、线程、文件、行号、函数、消息;
  • 该行前后的日志上下文,放大器场景下按字节截断;
  • 另一侧的联调日志:事件来自前端就附上后端日志尾部,反之亦然;
  • 代码索引结果,以及最近一次 feat 提交的 diff。

第三项值得单独说。前后端分离的系统里,一个前端报错往往只是后端失败的结果,只看一侧日志容易把责任判错方向。把同一时间窗口内另一侧的日志尾部一起给出,模型才有可能判断问题出在协议、数据还是某一侧的代码。

第四项解决的是”最近改了什么”的问题。大量线上错误由最近的提交引入,把 feat diff 作为线索附上,定位命中率会明显提高。

交付形态也不一样

评论触发的结果是回复,日志触发的结果是报告。

这个实现让模型按固定章节输出:摘要、严重程度、检查方向、检查思路、问题定位、修复建议、需要补充的信息。修复建议要求给出具体文件、函数、行号,以及修改前后的 diff,涉及数据修复时给出带 WHERE 条件的 SQL 并提醒先查影响行数。

报告写入报告目录,同时通过 webhook 或邮件推送,另有一个 Web 界面可以查看历史报告、切换模型、对单个日志文件触发一次分析。这就是这条链路里”回写”的位置:评论型 NPC 写回评论,日志型 NPC 写回报告与通知。

为什么不做成自动改代码

这套系统有定位能力,也有生成修复方案的能力,但没有让模型直接改代码。

原因有两层。一层是证据:日志只记录了失败瞬间的状态,很多错误需要复现步骤、数据库现状或配置才能确认,仅凭一段上下文给出的修复方案可能是错的。另一层是影响面:自动修改意味着自动提交,错误修改会进入主干并影响其他人。

所以它的提示词里明确要求只给建议,不输出任何要求系统自动执行的指令或结构化修复块,所有修复由开发者人工评估后执行。这个边界不是能力不足,而是对”谁来承担后果”的回答。

评论触发的 NPC 有一条类似的边界。它可以读代码、跑命令、给结论,但写操作要限定范围,危险操作要拦截,改动要能从评论记录里还原。

打包成镜像

课程把 Docker 打包留成课后实验,这里补一条能直接照做的最小路径。

镜像是把内核和入口脚本装在一起:前者是前面几章写好的 LLMClient、ToolRegistry、Agent 与工具集,后者负责读事件、调内核、写回结果。依赖控制在标准库加一个 SDK,镜像体积和启动时间都可控。

1
2
3
4
5
6
FROM python:3.12-slim
WORKDIR /app
COPY agent/ /app/agent/
COPY entrypoint.py /app/
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "/app/entrypoint.py"]

入口脚本的骨架只有三步:读环境变量、驱动 Agent、写回结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
from agent import build_agent

def main() -> None:
body = os.environ.get("COMMENT_BODY", "")
repo = os.environ.get("REPO_SLUG", "")
number = os.environ.get("ISSUE_NUMBER", "")
task = f"仓库 {repo} 第 {number} 条评论:{body}"

agent = build_agent(workspace=os.environ.get("WORKSPACE", "/workspace"))
answer = agent.run(task)
print(answer)

if __name__ == "__main__":
main()

变量名取决于平台注入什么,具体以平台文档为准;这里用一组自定义名字示意,入口脚本负责把它们翻译成任务描述。写回评论通常走平台的 API,或者在流水线配置里把标准输出接到评论接口,两条路选一条即可。

镜像做好之后,事件绑定就落在仓库的流水线配置里。课程仓库自己用的写法是把角色名映到一条流水线,stage 只声明镜像,由镜像的入口接管执行:

1
2
3
4
5
小光:
issue.comment@npc:
- stages:
- name: run agent and reply
image: ${CNB_DOCKER_REGISTRY}/${CNB_NPC_SLUG_LOWERCASE}:latest

有几个容器环境特有的约束值得提前处理。第一,没有终端,所有中间输出都必须落进日志或报告;第二,进程退出即销毁,回写评论这一步必须在退出前完成,超时时间要显式设置;第三,失败也要回写,否则用户只看到一条永远没有回复的评论;第四,镜像里不要放长期有效的凭据,用平台注入的服务身份。

落到自己项目上

把 Agent 做成”挂在工作流里自动干活”的形态,要回答的问题其实只有三个。

哪个事件值得触发。评论区里有人 @,日志里出现 ERROR,提交里出现 feat,都是明确的事件源。事件选得越准,无效调用越少。

去哪里找定位信息。代码索引、最近提交、日志上下文、联调日志,这几类材料决定了模型能看到什么。没有索引就只能在文本里猜,定位质量会差一个量级。

结果交付给谁。评论、报告、通知、Web 界面,选一个真正会被人看到的位置。跑完没人知道,等于没跑。

能力来自工具集,行为来自 Skills,可达性来自 NPC。三者叠加,一个 Agent 才具备被他人使用的条件。缺任何一环,它都还停留在”作者自己的工具”这个阶段。