AI 学习笔记(九):在开发工具里落地自动化工作流(从任务触发到结果回写)

上一篇我们把 MCP 与 Agent 的最小实践跑通了,解决的是“模型怎么拿到工具能力”。
这一篇继续 Phase 3,但问题从“会不会调工具”切到更贴近日常开发的一层:
怎么把 AI 接进开发工具,让任务从触发一路走到执行和结果回写,而且中间可追踪、可停止、可复用。

这一步很关键。MCP 负责的是协议层,让 Host、Client、Server 以及工具、资源、提示模板能对接起来;但它并不替你决定任务什么时候开始、上下文怎么拼、结果写回哪里。真正把自动化做稳,仍然要靠宿主侧的工作流设计。

这篇仍然只讲最小可落地方案,不追求“全自动研发平台”。
目标只有 3 个:

  • 有清晰的任务入口
  • 有稳定的执行编排
  • 有明确的结果回写

1. 先把问题拆对:协议层不等于自动化层

如果把“模型能调工具”直接等同于“自动化已经做完”,后面通常很快会踩坑。
因为开发工具里的自动化,至少还要回答 4 个问题:

  1. 谁来触发
    是开发者手动下命令、IDE 里的 hook、Issue/PR 事件,还是定时任务?
  2. 给模型什么上下文
    是整个仓库、最近 diff、失败日志,还是一段任务描述?
  3. 哪些动作允许自动执行
    只能读?能写草稿?还是可以直接改生产数据?
  4. 结果落到哪里
    是本地文件、PR 评论、Issue 评论,还是知识库文档?

上一篇讲的 MCP 在这里依然重要,但它主要解决的是“能力如何暴露给模型”。
自动化工作流真正要补的是这一层:

  • Trigger:任务入口
  • Context Packer:上下文装配
  • Orchestrator:步骤编排
  • Executor:工具执行
  • Writer:结果回写

一句话记住:

MCP 负责把工具接进来,自动化 Runner 负责决定什么时候启动、给它什么上下文、结果写回哪里。

2. 最小链路先固定成 5 段

很多自动化失控,不是模型不够强,而是“触发、执行、回写、确认”全部混在一起。
我更推荐先固定成下面这条最小链路:

环节 最小职责 常见坑
Trigger 生成统一任务输入 每个入口字段都不一样
Context Packer 只收集本次任务需要的上下文 把整个仓库一股脑塞给模型
Orchestrator 控制步骤、超时、重试、停止 没有 maxTurns 和超时
Executor 调模型、MCP、本地脚本或 API 让模型直接拥有高风险写权限
Writer 回写结构化结果与状态 只回一大段不可执行的文字

这个拆法的好处是:

  • 入口可以换,但核心编排不用重写
  • 工具可以换,但回写接口不用推倒
  • 以后从 CLI 迁到 Hook、再迁到 Webhook,也不会整条链路一起重做

3. 任务入口怎么选:先手动,再规则,最后异步

开发工具里的入口很多,但不建议一开始就“事件全自动”。
更稳的顺序通常是:

  1. 手动命令
    例如 npm run ai:task -- --type pr_review --pr 123
  2. 工具内 Hook
    例如在用户提交任务前补上下文,或在写文件后自动跑一次测试
  3. 异步事件
    例如给 Issue 打上 ai-ready 标签后,后台自动起任务并写回评论

这个顺序的核心不是保守,而是为了先把边界做清楚。
例如 Claude Code 的 hooks 可以在 UserPromptSubmitStopPostToolUse 这些生命周期点插入命令,适合补上下文、做后置校验、把本地自动化接进工作流;GitHub Actions 则很适合承接“Issue 被加标签后异步执行并回写结果”这种后台任务。

我的建议很简单:

  • 本地研发先从手动触发开始
  • 需要补充上下文或做后置校验时,再上 Hook
  • 任务开始超过几十秒、需要排队或要和代码托管平台联动时,再做 异步事件驱动

4. 上下文装配:别喂整个仓库,只喂本次任务需要的 4 类输入

自动化失败的另一个高频原因,是把上下文当成“越多越好”。
实际上在开发工具场景里,大多数任务只需要 4 类输入:

  1. 任务载荷
    例如 Issue 编号、PR 编号、当前命令参数、目标输出路径
  2. 仓库规则
    例如项目约束、目录规范、允许写入的位置
  3. 局部证据
    例如最近 diff、失败测试、构建日志、目标文件片段
  4. 回写目标
    例如写回 Issue 评论、本地报告文件,还是只做 dry-run

入口参数建议统一成一个 JobInput,不要每个入口自己发明字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface JobInput {
jobType: 'pr_review' | 'bug_summary' | 'doc_update';
sourceRef: string;
actor: string;
writeBack: {
type: 'github-issue' | 'github-pr' | 'file';
target: string;
};
constraints?: {
maxTurns?: number;
timeoutMs?: number;
writeMode?: 'dry-run' | 'safe-write';
};
}

统一 JobInput 的价值很大:

  • 入口从 CLI 换成 Webhook 时,编排层不用改
  • 回写从本地文件换成平台评论时,执行层不用改
  • 以后要做重试、排队、幂等,也有稳定主键可以挂

5. 一个可直接复用的最小 Runner

下面这份代码只做 5 件事:

  • 生成 jobIdtraceId
  • 收集本次任务最少上下文
  • 调一次受控 Agent
  • 把结果写回平台或文件
  • 落一份机器可读状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// tools/automation/job-runner.mjs
import fs from 'node:fs/promises';
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);

export async function runJob(jobInput) {
const jobId = jobInput.jobId ?? crypto.randomUUID();
const traceId = crypto.randomUUID();

await saveState({ jobId, traceId, step: 'collect_context', status: 'running' });
const context = await buildContext(jobInput);

await saveState({ jobId, traceId, step: 'execute', status: 'running' });
const result = await runAgent({
task: jobInput,
context,
tools: ['readDiff', 'readLogs', 'writeDraftComment'],
maxTurns: jobInput.constraints?.maxTurns ?? 4,
timeoutMs: jobInput.constraints?.timeoutMs ?? 120_000,
});

await saveState({ jobId, traceId, step: 'write_back', status: 'running' });
await writeBack(jobInput, result);

await saveState({ jobId, traceId, step: 'done', status: 'success' });
return { jobId, traceId, result };
}

async function buildContext(jobInput) {
const [rules, diff, failedLog] = await Promise.all([
fs.readFile('AGENTS.md', 'utf8'),
execFileAsync('git', ['diff', '--unified=0', 'HEAD']),
fs.readFile('.artifacts/last-failed.log', 'utf8').catch(() => ''),
]);

return {
jobInput,
rules,
diff: diff.stdout.slice(0, 12_000),
failedLog: String(failedLog).slice(0, 8_000),
};
}

async function runAgent({ task, context, tools, maxTurns, timeoutMs }) {
// 这里替换成你自己的模型调用层。
// 关键不是 SDK 名字,而是:
// 1. 工具白名单显式
// 2. 有 maxTurns
// 3. 有 timeout
return {
summary: `已完成 ${task.jobType}`,
suggestions: ['补充测试', '回写摘要到平台'],
evidence: ['logs:1', 'diff:2'],
toolsUsed: tools,
maxTurns,
timeoutMs,
};
}

async function writeBack(jobInput, result) {
const body = [
'### 自动化处理结果',
`- 结论:${result.summary}`,
`- 证据:${result.evidence.join(', ')}`,
`- 建议:${result.suggestions.join(';')}`,
].join('\n');

if (jobInput.writeBack.type === 'github-issue') {
await execFileAsync('gh', [
'issue',
'comment',
jobInput.writeBack.target,
'--body',
body,
]);
return;
}

await fs.writeFile(jobInput.writeBack.target, `${body}\n`, 'utf8');
}

async function saveState(state) {
await fs.mkdir('.artifacts/jobs', { recursive: true });
await fs.writeFile(
`.artifacts/jobs/${state.jobId ?? state.traceId}.json`,
`${JSON.stringify({ ts: new Date().toISOString(), ...state })}\n`,
'utf8'
);
}

这段代码刻意没有追求“写得多高级”,但已经把自动化最重要的约束放进去了:

  • 有统一 JobInput
  • jobId / traceId
  • 有步骤状态
  • 有工具白名单
  • maxTurns / timeoutMs
  • 有结果回写

很多时候,你真正缺的不是第二十个工具,而是第一条能稳定跑通的流水线。

6. 长任务不要阻塞交互,结果回写最好分两份

一旦任务变成“全仓扫描”“大 diff 审查”“批量文档修订”,同步执行就很容易把交互拖死。
这时候更稳的做法是:

  • 前台只负责创建任务
  • 后台异步执行
  • 完成后再把结果回写回来

这类场景里有三个很实用的做法:

  1. Hook 异步化
    适合本地开发时把测试、格式检查、摘要生成挂到工具生命周期后面
  2. 平台后台任务
    适合 Issue、PR、CI 失败这类本来就天然异步的场景
  3. 模型层后台执行
    如果模型调用本身就很长,可以让模型层用后台模式跑完后再通知宿主

例如 OpenAI 的 Background mode 允许把请求作为后台任务启动,再轮询状态;如果你不想主动轮询,也可以接 webhook,在 response.completed 等事件到来时再继续回写链路。

真正落地时,我建议把结果分成两份:

  • 给人看的摘要
    放 PR / Issue 评论里,3 到 5 行就够
  • 给程序读的状态
    放 JSON 或报告文件里,记录 jobIdtraceId、步骤、耗时、工具和结果状态

这样排查问题时不会只剩下一段“看起来说了很多、实际没法自动处理”的自然语言。

7. 一个很实用的触发方式:Issue 打标签,后台执行后回写评论

如果你已经在用 GitHub 管理任务,一个非常顺手的最小方案是:

  1. 给 Issue 打上 ai-ready
  2. GitHub Actions 被触发
  3. 后台执行 job-runner
  4. gh issue comment 把摘要写回

触发器本身可以非常薄:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: ai-issue-job

on:
issues:
types: [labeled]

jobs:
run:
if: github.event.label.name == 'ai-ready'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node tools/automation/run-issue-job.mjs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}

这套做法的优点很明确:

  • 触发条件清楚
  • 执行天然异步
  • 平台自带任务记录
  • 回写位置离任务本身很近

如果你现在还不想接平台事件,也完全可以把第一步替换成 npm run ai:issue -- --issue 456,剩下的编排和回写逻辑不变。

8. 别漏这 6 个工程细节

8.1 幂等

同一个任务重跑时,最好带固定 jobIdoperationId
如果你接 webhook,还要能按事件 ID 去重,避免重复回写。

8.2 权限分级

至少分三层:

  • read-only
  • write-safe
  • write-critical

高风险动作默认不要开放给自动化直写。

8.3 dry-run

本地开发默认先走 dry-run,只生成摘要、报告或草稿评论。
等流程稳定后,再放开 safe-write

8.4 可观测性

至少记录这些字段:

  • traceId
  • jobId
  • step
  • toolName
  • latencyMs
  • resultStatus

没有这些字段,后面几乎没法排障。

8.5 人在回路

MCP 官方在工具设计上也强调 human in the loop。
凡是会真实改代码、改配置、发消息、改工单状态的动作,都应该保留人工确认点。

8.6 长任务异步确认

如果你用了后台任务或 webhook,接收端应该尽快返回成功状态,把真正的执行放到后台。
回写时再通过评论、状态文件或第二个事件把结果补回来。

9. 一条更稳的演进路线

如果你准备把开发工具里的自动化慢慢做起来,我更推荐按下面这个顺序:

  1. 先做手动触发 + 本地 dry-run
  2. 再做统一 JobInput + 固定 5 步编排
  3. 再接 Hook,自动补上下文和后置校验
  4. 再接 Issue / PR / CI 事件,让任务异步执行
  5. 最后再考虑共享 MCP 服务、队列、长期记忆、多执行器

这个顺序的重点不是“功能最全”,而是每一步都能独立带来收益,而且不会过早把系统做复杂

总结

把 AI 自动化真正落进开发工具,重点从来不是“先做一个万能 Agent”,而是先把下面四件事做稳:

  • 触发清楚
  • 上下文收敛
  • 执行可停
  • 结果可回写

如果你只做一件事,我最推荐的是:

统一 JobInput、固定 5 步编排、同时保留人类可读摘要和机器可读状态。

这三件事做稳了,再去增加更多工具、更多策略、更多入口,复杂度才会可控。

下一篇开始进入 Phase 4:本地模型与云模型怎么协作,才能在成本、延迟和可用性之间做更稳的工程取舍。

参考资料

本文永久链接: https://www.mulianju.com/learning-notes/ai-learning-notes-devtools-automation-workflow-trigger-to-writeback/