拆解一个 LLM 工程化项目:16 个 Service + Agent 对话循环怎么协同跑流水线

最近在思考 LLM 多 Agent 协同的工程化落地,翻到一个叫 Auteur 的项目,作者用 Spring Boot 把"AI 生产中文短视频"这个场景拆成了 16 个独立 Service + 1 个 Agent 对话循环。读完源码觉得里面几个设计决策挺值得拿出来聊------特别是对所有"用 LLM 串多步流水线"的同学都有参考意义。

这篇不是项目介绍,是把里面的工程取舍单独拎出来分析。每个点都附独立可复用的代码模式,看完能直接搬到自己的 LLM 项目里。


一、为什么不能用 chain?

「LLM 串流水线」最直觉的写法就是 chain:

java 复制代码
// 反面教材:chain 写法
public Video generate(String topic) {
    Script s = scriptWriter.write(topic);
    Storyboard sb = storyboardArtist.draw(s);
    List<Image> imgs = imageGen.render(sb);
    Audio a = ttsService.speak(s);
    return composer.merge(imgs, a);
}

这种写法在 demo 里没问题,工程化场景会很快崩。Auteur 作者在 README 里复盘了三个具体的痛点,跟我自己踩过的坑高度重合:

  1. 第 4 步生图失败,前面三步白跑。 LLM 调用是有钱的,前三步 token 烧掉了,最后一步挂了重跑要再烧一遍。
  2. 中间产物只在内存里,UI 看不到。 用户说"这个镜头风格再暗一点",你得把整条 chain 重跑。
  3. 角色之间想加自审,要么改框架要么塞 if。 编剧要打分重写,摄影要校验时长------所有这些都得插到 chain 中间,写出来全是分支。

Auteur 的解法是:每个角色拆成独立 Service,角色之间不直接互相调用,全部通过数据库表解耦

复制代码
topic → script → storyboard_shot → image_asset → voice_asset
     → video_asset → published_video → weekly_review

每张表对应一个产物。读上游表,写下游表。任何一段失败可以单独重跑,不影响别的。

抽象出来的通用模式,可以直接搬到自己的 LLM 流水线项目里:

java 复制代码
// 通用 LLM 流水线 Service 范式
@Service
public class StoryboardService {
    private final ScriptRepository upstream;     // 读上游表
    private final StoryboardShotRepository self; // 写自己的表

    public void run(Long topicId) {
        Script s = upstream.findByTopicId(topicId);
        List<Shot> shots = llmDraw(s);
        self.saveAll(shots);  // 落库,下游异步消费
    }
}

要点:

  • 不要在 ServiceA 注入 ServiceB,否则又退化成 chain
  • 每段产物都落库,UI 才能展示+人工介入
  • 触发用显式调用或事件,但消费走数据库读

作者还提到一个有意思的细节:第二版他试过用 Spring Events 解耦,结果"事件链一长跟 chain 没本质区别,调试更难"。最后还是回到「产物落库 + 显式触发」。这个结论我蛮赞同------事件驱动在边界清晰的领域很好,但 LLM 流水线的边界本身就是模糊的,事件链一长根本理不清谁触发了谁。


二、自审 Loop 的工程化处理

LLM 生成的内容经常像草稿。最朴素的应对是换大模型 / 写更细 prompt。前者贵,后者反噬------prompt 越细 LLM 越抓不住重点。

Auteur 给关键角色(编剧、摄影、美术)配了一个自审 Service。骨架很简单:

java 复制代码
public ScriptResult writeWithSelfReview(Long topicId) {
    ScriptResult draft = scriptWriter.write(topicId);
    CriticResult review = scriptCritic.review(draft);
    if (review.score() < threshold) {
        return scriptWriter.rewriteWithFeedback(draft, review.feedback());
    }
    return draft;
}

代码两行,但里面有三个工程化细节是踩出来的:

(1) 自审 prompt 必须"找问题"导向,不能"打分"导向

让 LLM"列出 3 个最大的问题"比让它"给个 80 分"有用得多。打分版本会出现"凑分数"现象------LLM 看见草稿写得不错就给个高分混过去。我自己也验证过这个:同一份草稿,"打分"版本均分 82,"找问题"版本能稳定挖出 2-3 个具体的修改点。

(2) 重写最多重跑 1 次

再不行就放过原稿。LLM 钻牛角尖比放过去还糟------会把一个本来还行的稿子改得越来越奇怪。这个在多轮 critic loop 里特别明显,一定要硬限。

(3) 自审失败必须降级

java 复制代码
try {
    review = scriptCritic.review(draft);
} catch (Exception e) {
    log.warn("自审失败,使用原稿", e);
    return draft;  // 不让自审挂了导致整个流水线挂
}

这条是最容易忽略的。自审本身也是 LLM 调用,会失败、会超时、会返回格式异常。绝对不能让自审失败传染下游。


三、镜头时长锚定(最值得抄的设计)

这是我看完整个项目觉得最巧的一个设计。

普通流水线让 LLM 给每个镜头估时长("这个镜头 3.5 秒"),后端按这个时长拼图。问题是 LLM 估的时长跟真实 TTS 音频对不上,剪出来字幕飘 1-2 秒。加更细的 prompt 让 LLM 估准也没用------它压根不知道你的 TTS 模型每秒念几个字。

Auteur 的解法是反过来:让 LLM 不再估秒数,只负责"指认"脚本里的一段连续文本。后端拿到 SRT 字幕后,去音频时间轴上反查这段文本的真实秒数。

arduino 复制代码
镜头 5 anchor: "她推开门的那一刻"
        ↓
SRT 反查: 这句在 12.34s - 14.78s 之间
        ↓
镜头 5 时长 = 2.44s(真实音频时长)

抽象出来是一个通用模式------把 LLM 不擅长的"量化估算"换成它擅长的"定位指认"。这个思路不止能用于视频,任何需要 LLM 跟外部信号对齐的场景都适用:

java 复制代码
// 通用「锚定」模式
public Anchor resolveAnchor(String llmAnchor, List<Segment> segments) {
    String normalized = normalize(llmAnchor); // 去标点 / 全半角 / 大小写
    for (Segment seg : segments) {
        if (normalize(seg.text()).contains(normalized)) {
            return new Anchor(seg.startMs(), seg.endMs(), true);
        }
    }
    return Anchor.unmatched();  // 标记未命中,降级处理
}

校验链作者做得比较狠:

  • anchor 必须是脚本的连续子串(normalize 之后比对)
  • 相邻镜头的 anchor 在脚本里位置必须单调递增(防 LLM 把镜头顺序搞乱)
  • 没命中的镜头标 anchor_match=false,视频还能渲,但日志和 UI 都会提示

这个"严格校验 + 软降级"的组合也很值得抄------校验严是为了保证数据可信,但失败了不能让整个流水线挂。


四、Agent 对话循环的工具注册机制

光有流水线还不够,作者在上面加了一个 Agent 聊天工作台------本质是带工具调用 + 审批门槛 + Skill 加载的对话循环。

工具用 @Tool 注解扫描自动注册:

java 复制代码
@Component
public class StoryboardTools {
    @Tool(name = "regenerate_image_for_shot",
          description = "重新为指定 shot 生成图片")
    public RunRef regenerateImageForShot(Long shotId, String stylePatch) {
        // ...
    }
}

启动时 ToolRegistry 扫所有 @Tool 标注方法,反射拿参数类型生成 JSON Schema 注册给 LLM。加新工具不用改 Agent 主循环代码。这个机制本身没什么新意------LangChain、Spring AI 都是类似做法------但作者多做了两件事我觉得是 LLM Agent 工程化的关键:

(1) 写操作必须实现 PreviewableHandler

java 复制代码
public interface PreviewableHandler {
    PreviewCard preview(ToolCall call);  // 返回前端展示的审批卡
    Object execute(ToolCall call);        // 用户点确认才调
}

任何「改预设、删数据、触发长任务」的工具都强制走审批。前端弹一张卡,用户点确认才执行。作者在 README 里写:"一开始没加这个,调试时 Agent 自作主张把一个预设的 prompt 改了,发现的时候改回去花了我半小时"。

这是把 LLM 不可控性挡在副作用之外的最后一道闸。任何让 LLM 自动执行写操作的项目都应该有类似机制------哪怕信任度高,也要有"用户最后看一眼"的环节。

(2) Skill 按需加载,不全塞 system prompt

scss 复制代码
agent/skills/
  ├── adjust-content.md
  ├── trigger-pipeline.md
  ├── create-topic.md
  ├── edit-preset.md
  └── edit-text.md

把不同类型的剧本写成 markdown,Agent 自己根据当前对话决定加载哪份。作者一开始全塞 system prompt 里,"token 涨得很快,回答质量反而下降"。

这个现象有共识------LLM 的 prompt 不是越长越准。Anthropic 自己也讲过 context rot:当上下文塞太多东西,模型会丢失对关键指令的注意力。Skill 按需加载是个低成本优化,值得在所有 Agent 项目里加。

(3) 长任务异步化,工具返 runId

生图、视频合成这种动辄几十秒的任务,如果同步等会让 Agent 阻塞。作者的做法是工具立刻返回 runId,前端轮询 /api/runs/{id} 看进度。Agent 主循环不被卡住。

java 复制代码
@Tool(name = "regenerate_image", description = "...")
public RunRef regenerate(Long shotId) {
    String runId = runService.startAsync(() -> doRegenerate(shotId));
    return RunRef.of(runId);  // Agent 立刻拿到引用,可以继续对话
}

五、降级链路的「不可省」

外部依赖缺失时后端不能挂。作者把降级写得比较彻底:

依赖 缺失时的行为
TOS(对象存储) 走本地路径 + /api/files/... 静态服务
火山 TTS 配音环节 disabled,前端显示 notice
Jamendo(BGM) BGM 推荐 off,制片照常合成
Remotion 走纯 ffmpeg 路径
LLM 网关 走 OpenAI 兼容协议,自部署 vLLM / DeepSeek / 智谱 / Anthropic 都行

写起来到处都是 if + 兜底,但这事不能省。开源项目你不知道用户机器上装了啥,启动失败一次基本就被 uninstall 了。

通用模式:

java 复制代码
@Component
public class TtsService {
    @Value("${app.tts.enabled:false}")
    boolean enabled;

    public Optional<Audio> synthesize(String text) {
        if (!enabled) {
            log.info("TTS disabled, skipping voice synthesis");
            return Optional.empty();
        }
        return Optional.of(doSynthesize(text));
    }
}

调用方都拿 Optional,处理"没有这个能力"是常态而非异常。比 throw + 上层 catch 干净得多。


六、几个工程化踩坑(跟 LLM 无关但很有用)

作者在 CLAUDE.md(写给 AI 助手的项目说明文件)里列了一些踩坑,几个跟所有 Spring Boot 项目都相关:

1. .gitignore 必须用前导 / 锚定

git 的目录模式会递归匹配所有层级 ------storage/ 不光忽略 backend/storage/ 产物目录,还会一并忽略 backend/src/main/java/com/auteur/storage/ 这个业务包。CI 编译挂了你都不知道为啥。

正确写法/storage/,只匹配 git 根下的同名目录。

.dockerignore 语义不同(用 Go filepath.Match,不递归),所以同样的 storage/ 在 dockerignore 里没问题。这种语义差异会让本地 docker build 通过、CI 编译挂掉,非常隐蔽。

2. Alpine 容器 localhost 优先解析 IPv6

但 nginx 默认只监听 IPv4,所以 healthcheck 永远失败。改用 127.0.0.1 即可。这个坑不写 Dockerfile 的同学很少遇到,但遇到了能调一下午。

3. Spring Boot ddl-auto: validate 严格校验 entity ↔ schema

加字段忘 migration 启动失败。强制走 Flyway,跟生产保持一致。

4. Remotion 不支持 file:// 协议

本地静态文件得走 HTTP URL,配 auteur.video.remotion.public-base-url。所有"浏览器内渲染本地文件"的项目都有这个问题。


总结

把 LLM 流水线写得能用很容易,写得能跑能改能恢复需要做大量工程化决策。Auteur 这个项目里值得拿走的几个模式:

  1. Service 之间走 DB 解耦,不要互相注入------产物落库才能重跑、回滚、人工介入
  2. 关键角色配自审 Service,但 prompt 要"找问题"不要"打分",重试上限 1 次,自审失败必须降级
  3. 量化估算转定位指认------锚定模式让 LLM 跟外部信号对齐
  4. Agent 写操作必须有审批门槛,Skill 按需加载,长任务返 runId 异步化
  5. 外部依赖全部可降级 ,每个外部 Service 都包成 Optional

这些模式不依赖具体业务,所有"LLM + 多步流水线"的项目都能套。技术栈是 Spring Boot 3.3 + JPA + Flyway + MySQL + Java 21 + Vue 3 + Remotion,整体偏 Java/JVM 生态。

文中提到的项目源码:github.com/nxin-github... (MIT 协议)


你们做 LLM 流水线时怎么处理「中间步骤失败」的?是整条重跑,还是只重跑挂掉的那一段?我自己一直纠结要不要给每段加缓存层。

相关推荐
澄旭1 小时前
拆解一个成熟 Skill,看懂 Skill 到底该怎么写
人工智能
沪漂阿龙1 小时前
《LangChain 系列》Human-in-the-loop:什么时候必须让人工介入?
人工智能·架构·langchain
冬哥聊AI1 小时前
Loop Engineering 来了:从写 Prompt 到设计 Loop,AI 编程的第四次范式跃迁
人工智能
阿文和她的Key1 小时前
AI Agent突然到处都是了——聊聊它是什么,非技术也能看懂
agent
FliPPeDround1 小时前
告别离线 Agent:deepseek-kit 内置 Web Search,零配置联网搜索
javascript·agent·deepseek
柒星栈1 小时前
Codex 不只是更强的代码助手,它开始像代理一样推进开发任务了
人工智能
o_insist2 小时前
04-从零手写 ReAct 循环:Agent 的心跳是怎么转起来的
人工智能·agent
DayByDay2 小时前
从“单专家”到“多专家辩论”:多大脑对话实现复盘
人工智能