您的位置:首页 > 手游攻略 > 拆解一个 LLM 工程化项目:16 个 Service 加 Agent 对话循环如何协同跑流水线

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

作者:互联网  时间: 2026-07-02 09:04:52  

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

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

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


一、为什么不能用 chain?

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

 复制代码// 反面教材: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 流水线项目里:

 复制代码// 通用 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。骨架很简单:

 复制代码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) 自审失败必须降级

 复制代码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 字幕后,去音频时间轴上反查这段文本的真实秒数。

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

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

 复制代码// 通用「锚定」模式
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 注解扫描自动注册:

 复制代码@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

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

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

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

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

 复制代码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 主循环不被卡住。

 复制代码@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 了。

通用模式:

 复制代码@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 协议)


最新游戏

更多

Copyright©2010-2019. All rights reserved | 波波三国游戏官网|[email protected]

备案编号:湘ICP备2022015115号-4