智能运营agent

\ResumeItem{OGV 智能运营 Agent 平台 \hfill Agent应用开发 \hfill 2026.07-2026.09}
\textbf{核心技术:}Java、Spring Boot、AgentScope、DashScope、Redis、Reactor、Paladin、BFS、PDFBox、EasyExcel

\textbf{项目简介:}参与面向 OGV 运营场景的多 Agent 智能操作平台建设;本人主要负责长任务执行与恢复、业务子 Agent 动态配置及多格式文档理解模块
\begin{itemize}
\item 负责 Agent 长任务执行机制,将复杂任务抽象为计划、步骤和批次,基于 Redis 持久化任务状态、计划版本、执行游标和操作结果,实现用户一次确认后的自动续跑以及暂停、继续、停止、重试与服务重启恢复

\item 为计划内写操作设计稳定操作标识和结果记录机制,并通过执行守卫限制未创建任务、未提交计划或未经确认时执行批量写操作,降低模型重试和网络异常导致的重复执行及越权调用风险

\item 负责业务子 Agent 配置化改造,支持通过 Paladin 动态配置系统提示词、Tool、Skill 和权限码;实现配置解析、合法性校验、热更新及 Agent 缓存失效,降低新增业务域 Agent 的代码改动成本

\item 负责多格式文档理解链路,支持 XLSX、CSV、DOCX、PDF、PPTX、TXT、JSON 等 7 类文件的异步解析、内容分片、内嵌图片提取、BFS 上传和 Redis 临时存储,使 Agent 能按需读取文档而非一次性注入全文

0. 总体

0.1 个人问题

0.2 AI问题

下面的问题更关注“整个项目为什么做、怎么设计、如何落地、你的贡献是什么”,适合作为项目整体介绍后的面试追问。回答是示例话术,涉及数据的部分需要替换成真实情况。

1. 这个平台主要解决什么业务问题?

OGV 运营工作中存在大量跨系统、重复且步骤较长的操作,例如读取运营材料、生成执行方案、调用业务工具并批量落地。传统方式依赖人工串联,效率较低,也容易出现遗漏。平台希望通过多个业务 Agent 协作,把自然语言需求转换为可确认、可执行、可恢复的任务。

2. 为什么要做多 Agent,而不是一个大模型加一组 Tool?

不同业务域的提示词、工具、权限和操作规则差异较大。如果全部放到一个 Agent 中,上下文会比较复杂,工具选择也更容易混乱。我们使用主 Agent 负责任务理解和路由,业务子 Agent 负责具体领域能力,从而实现职责隔离、权限隔离和独立迭代。

3. 系统整体架构是什么样的?

整体可以分为四层:

  • 接入层:接收用户请求、文件和任务控制指令。
  • Agent 编排层:完成意图识别、子 Agent 路由、计划生成和执行控制。
  • 能力层:提供业务 Tool、Skill 和文档理解能力。
  • 基础设施层:Redis 保存任务状态,Paladin 管理动态配置,BFS 存储文件,业务服务负责实际操作。

4. 一个用户请求从进入系统到执行完成,完整流程是什么?

用户请求首先由主 Agent 理解,并选择对应的业务子 Agent。子 Agent 根据用户输入和文档内容生成执行计划;涉及写操作时,需要用户确认。确认后,系统按照计划逐步调用工具,持续记录执行进度和结果,最后汇总执行情况返回给用户。

5. AgentScope 和 DashScope 分别承担什么角色?

AgentScope 主要用于组织 Agent、Tool 调用和多 Agent 协作流程;DashScope 提供底层大模型能力。我们在框架之上补充了任务状态管理、权限控制、动态配置和文档理解等面向生产环境的能力。

6. 这个系统与普通聊天机器人的核心区别是什么?

普通聊天机器人主要生成文本,而这个系统需要执行真实业务操作。因此除了模型效果,还必须解决任务状态、权限、幂等、异常恢复、人工确认和可观测性等工程问题。核心目标不是“回答得像”,而是“执行得正确且可控制”。

7. 系统如何划分主 Agent 和业务子 Agent 的职责?

主 Agent 负责理解需求、识别业务域和选择子 Agent,不直接承载复杂业务规则。业务子 Agent 只暴露自己领域内的 Tool 和 Skill,并配置对应权限。具体写操作最终仍由确定性的业务接口执行,模型主要承担理解、规划和参数组织。

8. 为什么让模型生成计划,而不是直接调用业务接口?

复杂运营任务通常包含多个步骤,而且用户需要在执行前了解系统准备做什么。显式计划可以提高可解释性,也方便用户确认、任务恢复和失败重试。对于简单只读操作,可以缩短流程;对于批量写操作,则必须经过计划和确认。

9. 如何控制模型的不确定性?

主要通过缩小模型的决策范围来控制。模型负责理解和规划,但实际执行依赖结构化 Tool;工具参数需要经过校验,写操作还受到任务状态、用户确认和权限的约束。同时记录每一步输入、输出和状态,方便发现问题和人工介入。

10. 为什么选择 Redis 保存任务状态?

任务状态需要频繁读写,并且要支持服务重启后恢复。Redis 的访问延迟较低,也适合保存任务游标、执行状态和临时结果。对于有明确生命周期的数据,可以通过 TTL 控制存储时间;如果后续需要长期审计,还需要再落到持久化数据库。

11. 系统如何保证业务安全?

安全控制不依赖模型自行判断。我们从 Agent 能看到哪些 Tool、调用者是否具备权限、任务是否已经确认、当前状态是否允许执行等多个层面进行校验。真正的业务接口还会保留自身的鉴权和参数校验,Agent 层不会绕过原有安全边界。

12. 多 Agent 之间如何协作,是否会互相直接调用?

目前以主 Agent 路由到业务子 Agent 为主,尽量减少子 Agent 之间自由调用。这样执行链路更容易追踪,权限关系也更清晰。如果任务需要跨业务域协作,由编排层统一组织,而不是让 Agent 自主形成不可控的调用链。

13. 项目中最难的工程问题是什么?

最难的不是接入模型,而是把模型的概率性输出接入确定性的业务系统。尤其是长任务、批量写操作和异常重试场景,需要同时处理任务状态、用户确认、幂等、恢复和权限问题。我们的思路是把这些约束放在确定性的执行框架中,而不是依靠提示词保证。

14. 如何评估这个平台是否有效?

可以从三类指标评估:

  • 效率:任务平均耗时、人工操作步骤减少量。
  • 质量:计划成功率、工具调用成功率、任务完成率。
  • 安全与稳定性:重复写入次数、异常恢复成功率、越权拦截数量。

面试时最好补充真实数据,例如“平均处理时长从多少降低到多少”,不要编造指标。

15. 你在项目中的职责边界是什么?

我主要负责平台中偏执行基础设施的部分,包括长任务执行与恢复、业务子 Agent 配置化以及多格式文档理解。主 Agent 的部分路由逻辑和具体业务 Tool 由团队其他成员共同建设。我也参与了这些模块之间的协议设计和联调,但不会把整个项目描述成个人独立完成。

16. 你和业务方、其他研发是如何协作的?

业务方负责明确实际操作流程、风险点和验收标准;各业务研发提供可调用的领域接口;平台侧负责 Agent 编排、统一协议和安全控制。对于不明确的业务语义,我们会先固化为结构化规则或工具参数,再交给模型使用。

17. 项目上线前如何测试?

除了普通单元测试和接口测试,还需要重点覆盖 Agent 场景,包括模型输出不合法、工具调用失败、执行中断、重复请求、权限不足、配置热更新和大文件解析等情况。对于写操作,会在测试环境使用可回滚或模拟数据验证完整执行链路。

18. 出现线上问题时如何定位?

需要通过任务 ID 串联用户请求、Agent 路由、计划版本、步骤状态和工具调用结果。定位时先判断是模型规划问题、框架状态问题,还是下游业务接口问题。对于执行失败的任务,可以根据任务状态决定重试、继续执行或人工终止。

19. 这个项目有哪些设计取舍?

我们没有追求完全自主的 Agent,而是选择“模型规划加确定性执行”的方式。这样灵活性会受到一定限制,但更适合涉及真实业务写操作的场景。配置化也不是无限开放,而是在可校验的 Tool、Skill 和权限范围内提供动态能力。

20. 如果重新做一次,你会改进什么?

我会更早建立统一的任务事件模型和评测体系。任务事件模型可以降低状态排查和审计成本;评测体系可以通过固定用例持续衡量路由、计划和工具调用效果,而不是主要依赖人工体验。另外,长期任务数据应进一步区分临时状态与持久化审计数据。

1. 负责 Agent 长任务执行机制,将复杂任务抽象为计划、步骤和批次,基于 Redis 持久化任务状态、计划版本、执行游标和操作结果,实现用户一次确认后的自动续跑以及暂停、继续、停止、重试与服务重启恢复

1.1 具体实现方式

1. 背景与目标

普通 Agent 通常在一次请求中完成“推理、调用 Tool、返回结果”。但批量运营任务可能包含多个步骤、几十个对象、异步等待和真实写操作,单轮请求无法可靠完成。

这套机制的核心目标是:

将模型生成的长任务转换为服务端可持久化、可确认、可分批推进、可恢复的状态机。

核心代码集中在 app/ogv-agent-gateway 模块。

组件 职责
AgentTaskCheckpoint.java 定义任务、计划、游标和运行轮次
AgentTaskCheckpointService.java 实现任务状态机
AgentTaskCheckpointStore.java Redis 持久化与原子 CAS 更新
AgentTaskCheckpointTool.java 向 Agent 暴露任务操作
DeterministicBatchCoordinator.java 服务端确定性推进批次
LongTaskExecutionGuardMiddleware.java 限制模型的调用顺序和执行范围
ChatService.java 续跑事件接入、轮次收口
SKILL.md 约束 Agent 的长任务工作流

2. 核心数据模型

AgentTaskCheckpoint 保存恢复任务所需的最小事实,而不是依赖对话历史猜测执行位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private Status status;
private long planVersion;
private long confirmedPlanVersion;
private List<PlanStep> steps;
private String stepCursor;
private int currentStepNo;
private int nextStepNo;
private String progressDigest;

private String runId;
private long runLeaseUntil;
private long version;

private String pendingNotificationId;
private ContinuationKind pendingContinuationKind;

这些字段分别解决:

  • planVersion:当前计划版本。
  • confirmedPlanVersion:用户确认过的版本,二者不同就不能执行。
  • currentStepNo/nextStepNo:高层步骤位置。
  • stepCursor:步骤内部的批次位置。
  • version:Redis 乐观锁版本,防止旧请求覆盖新状态。
  • runId/runLeaseUntil:标识唯一执行轮次及其租约。
  • pendingNotificationId:标识唯一待消费续跑事件。
  • progressDigest:保存累计结果摘要。

当前实现没有通用的 List<OperationResult>。操作结果主要以进度摘要、失败摘要、下游引用和异步任务引用的形式保存。

3. 任务状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PLANNING
|
| submitPlan
v
AWAITING_PLAN_CONFIRMATION
|
| CONFIRM
v
WAITING_CONTINUATION
|
| consume notification
v
RUNNING
|
+--> WAITING_CONTINUATION
+--> WAITING_ASYNC_RESULT
+--> WAITING_RETRY
+--> RECONCILIATION_REQUIRED
+--> COMPLETED / FAILED / CANCELLED / EXPIRED

任务状态定义见 AgentTaskCheckpoint.java。

4. 从创建到执行的完整流程

4.1 创建任务

Agent 判定任务无法在单轮完成后,调用 agent_task_create。服务端生成 UUID,初始化为 PLANNING,同时设置任务截止时间和 Redis 过期时间。

1
2
3
4
5
checkpoint.setStatus(Status.PLANNING);
checkpoint.setVersion(1L);
checkpoint.setTaskDeadlineAt(now + continuationLifetimeMillis);
checkpoint.setStorageExpireAt(taskDeadlineAt + terminalRetentionMillis);
store.create(checkpoint);

此时只是建立任务容器,不能执行写操作。

4.2 提交完整计划

agent_task_submit_plan 将任务拆成高层步骤,每个步骤可包含结构化批次:

1
2
3
4
5
6
7
8
9
10
11
{
"title": "批量生成图片",
"sideEffect": true,
"batch": {
"action": "image-generate-submit",
"items": [
{"prompt": "方案一"},
{"prompt": "方案二"}
]
}
}

服务端会规范化步骤编号、校验批次执行器和参数,然后更新:

1
2
3
4
checkpoint.setPlanVersion(checkpoint.getPlanVersion() + 1);
checkpoint.setPlanDigest(sha256(canonicalPlan));
checkpoint.setConfirmedPlanVersion(0);
checkpoint.setStatus(Status.AWAITING_PLAN_CONFIRMATION);

计划发生修改后,旧确认自动失效,必须重新确认新版本。

4.3 用户一次整体确认

用户回复“确认执行”后,ChatService 将自然语言转换为受控 CONFIRM 命令:

1
2
3
checkpoint.setConfirmedPlanVersion(checkpoint.getPlanVersion());
checkpoint.setStatus(Status.WAITING_CONTINUATION);
continuation = scheduleContinuation(checkpoint, ContinuationKind.NORMAL);

之后每个步骤不再重复询问用户。确认针对的是完整计划版本,而不是某一次模型推理。

4.4 开始唯一运行轮次

续跑事件到达后,beginRun 校验:

  • notificationId 是否仍是当前 pending 事件;
  • 续跑类型是否匹配;
  • confirmedPlanVersion == planVersion;
  • 任务是否过期;
  • 当前状态是否允许执行。

校验成功才创建:

1
2
3
checkpoint.setStatus(Status.RUNNING);
checkpoint.setRunId(UUID.randomUUID().toString());
checkpoint.setRunLeaseUntil(now + runLeaseMillis);

Redis 中还保存 {taskId}|{runId} 的当前运行标记,防止两个续跑请求同时推进任务。

5. 确定性批次与执行游标

批次游标格式为:

1
batch:{stepNo}:{nextItemIndex}

例如 batch:3:5 表示第 3 个高层步骤下一次从第 6 个工作项开始。

核心循环位于 DeterministicBatchCoordinator.java:

1
2
3
4
5
6
7
8
9
10
11
int index = cursorIndex(checkpoint.getStepCursor(), currentStepNo, items.size());

while (index < items.size() && completed < executor.maxBatchSize()) {
checkpoint = checkpointService.prepareOperation(..., cursor(stepNo, index));
itemResult = executor.execute(items.get(index), runtimeContext, itemContext);
index++;

checkpoint = checkpointService.recordOutcome(
..., index == items.size() ? "STEP_COMPLETED" : "STEP_PROGRESS",
summary, index == items.size() ? null : cursor(stepNo, index), nextAction);
}

每完成一项就持久化新游标。请求超时或进程退出后,不需要重新执行整个批次,只需要从 Redis 中的下一游标继续。

当前确定性批次执行器通过 BatchWorkExecutor 注册;现有代码中明确实现该接口的是图片生成 Tool,每轮最多处理 5 项。普通长任务仍可以按照高层步骤由 Agent 推进。

6. 自动续跑如何实现

单轮预算默认是请求超时时间的 80%。当前配置为请求超时 10 分钟,因此约 8 分钟后停止启动新业务调用。

如果本轮取得进展但任务未完成,finishCurrentRun 会:

  1. 将 RUNNING 转换为 WAITING_CONTINUATION;
  2. 清除本轮 runId;
  3. 生成唯一 pendingNotificationId;
  4. 将 checkpoint 更新和通知入队放在同一个 Redis Lua 脚本中完成。

通知携带一条隐藏消息:

1
2
继续执行系统任务,taskId=...,notificationId=...,kind=NORMAL。
开始前必须重新读取任务状态。

Web 端通过 ChatNotificationController.java 拉取通知,再以隐藏请求调用聊天接口。因此正常批次推进不需要用户再次回复“继续”。

需要准确说明:当前普通长任务的自动续跑是“Redis 持久化通知 + 客户端隐藏请求”驱动,不是服务端线程在后台永久循环执行。

7. 暂停、继续、停止和重试

统一入口是 controlTask:

  • PAUSE:保存原可恢复状态到 resumeStatus,进入 PAUSED,清理当前运行租约。
  • CONTINUE:恢复 resumeStatus,根据是否存在未闭合操作,选择正常续跑、结果核对或异步监控恢复。
  • CANCEL:进入 CANCELLED,清理后续通知和运行信息,不再启动新步骤。
  • RETRY:只允许从 WAITING_RETRY 或 RECONCILIATION_REQUIRED 发起。

暂停和停止是协作式控制:它们可以阻止后续步骤,但不能撤销已经发送到下游的写请求。

8. Redis 原子性与并发控制

Store 使用 Lua 脚本同时更新:

  • checkpoint JSON;
  • 活动任务集合;
  • 当前运行轮次;
  • 续跑通知队列;
  • 当前选中任务。

关键 CAS 判断是:

1
2
3
if tonumber(decoded['version']) ~= tonumber(ARGV[1]) then
return 0
end

只有 expectedVersion 与 Redis 当前版本一致时才能保存。这样可以拒绝旧轮次、重复通知和并发控制请求。

Redis Key 使用相同的用户、会话 Hash Tag,使相关 Key 落在同一 Redis Cluster slot,从而能够由同一个 Lua 脚本原子更新。

9. 服务重启恢复

恢复依赖 Redis 中的持久化事实,不依赖 JVM 内存和模型对话记忆。

  • 通知尚未消费:服务恢复后仍可从 Redis 通知队列拉取并继续。
  • 批次部分完成:根据 currentStepNo + stepCursor 从下一项继续。
  • 执行中服务退出:runLeaseUntil 到期后,下一次加载任务会执行生命周期归一化。
  • 尚未发起副作用:进入 WAITING_RETRY,可以安全重试。
  • 已有异步任务:进入 WAITING_ASYNC_RESULT 或恢复监控。
  • 副作用已发起但结果未知:进入 RECONCILIATION_REQUIRED,先查询真实结果。

当前实现不是“应用启动时全量扫描并立即拉起所有长任务”,而是由未消费通知、异步轮询事件或用户再次进入会话触发恢复。

10. 执行守卫的作用

Prompt 和 Skill 只是指导,真正的安全约束位于 Tool 调用前的 Middleware。它会拒绝:

  • 没有创建任务就直接执行多项业务操作;
  • 没有保存计划或计划未经确认;
  • 未读取真实 checkpoint 就执行控制命令;
  • 没有有效 runId 就执行计划步骤;
  • 核对轮次再次执行写操作;
  • 超过单轮预算后启动新业务调用;
  • 任务状态调用和业务调用放在同一批执行;
  • 一个 reasoning 同时启动多个非并行安全业务 Tool。

这使模型只能在服务端允许的状态转换范围内行动。

11. 这项工作的准确总结

这项工作的本质不是“用 Redis 保存一个任务状态”,而是建立了一套长任务执行协议:

通过持久化计划版本、状态机、步骤和批次游标,将长任务拆成多个有租约的执行轮次;用户只需整体确认一次,后续由 Redis 通知驱动自动续跑;通过版本 CAS、运行租约和执行守卫防止旧请求并发推进;在超时或服务重启后,根据 checkpoint 和下游操作引用恢复到安全状态。

需要保留两个技术边界:

  1. 系统提供的是可恢复和幂等控制,不应宣称跨下游系统严格 exactly-once。
  2. 当前专门的长任务测试主要覆盖执行守卫,CheckpointService/Store/Coordinator 缺少直接单元测试,面试中不宜表述为“状态机已有完整自动化测试覆盖”。

1.2 提问

1.2.1 自己想到的问题

1.2.2 AI想到的问题

  1. 为什么需要单独设计长任务机制,直接让 Agent 连续调用 Tool 不行吗?

参考回答:普通 Agent 更适合在一次请求中完成较短的推理和工具调用,但复杂运营任务可能包含多个依赖步骤、批量写操作和异步等待,通常无法在一轮对话中稳定完成。如果只依赖模型上下文,一旦请求超时、模型重新生成、服务重启或者用户中途暂停,就很难准确判断之前执行到了哪里,还可能重复调用写接口。因此我把任务计划、执行位置和操作结果持久化,由服务端负责确定性推进,Agent 主要负责理解目标、生成计划和调用受控工具。

  1. 整个长任务模块由哪些组件组成?各自负责什么?

参考回答:整体上可以分成五部分:任务服务负责状态流转和业务校验;Redis 存储负责持久化任务事实;批次协调器负责按照已保存的进度逐项执行;续跑机制负责在一轮结束后触发下一轮;执行守卫负责在工具真正调用前做安全检查。各层职责是分开的,模型不能直接修改任务状态,存储层也不决定业务流程。这样即使更换 Agent 或者服务实例,任务仍然能够按照同一套规则推进。

  1. 计划、步骤和批次是怎么建模的?

参考回答:计划表示用户确认的完整执行方案,步骤表示有先后依赖关系的业务阶段,批次表示某个步骤中需要重复处理的一组同类对象。例如“为 100 个内容创建运营配置”可以拆成数据校验、批量创建、结果汇总三个步骤,其中批量创建步骤再拆成多个小批次。每个批次完成后都会保存进度,因此发生故障时只需要从未完成的位置继续,而不是重新执行整个计划。

  1. 任务状态机包含哪些状态?

参考回答:从业务阶段看,状态可以分为计划生成、等待用户确认、等待调度、正在执行、等待异步结果、等待重试、结果核对、暂停以及终态。终态又包括完成、失败、取消和过期。状态机不仅用于展示任务进度,更重要的是限制允许执行的动作,例如计划未确认时不能执行写操作,结果不确定时只能查询已有结果,已经结束的任务不能再次启动。

  1. 为什么同时保存 planVersion 和 confirmedPlanVersion?

参考回答:一个表示当前最新计划,另一个表示用户实际确认过的计划。每次用户补充要求或者系统重新生成计划时,当前计划版本都会变化,原来的确认立即失效。任务开始执行前必须确认“用户确认的版本就是当前版本”,从而避免用户确认的是旧方案,系统实际执行的却是修改后的新方案。这本质上是把用户授权和计划内容绑定在一起。

  1. “用户一次确认后自动续跑”是怎么实现的?

参考回答:用户确认完整计划后,系统会保存确认结果并创建一条续跑事件。执行器收到事件后读取最新任务状态,完成当前步骤或当前批次,再把新的执行位置和结果写回 Redis。如果后面还有工作,系统继续创建下一条续跑事件;如果需要等待异步结果,则暂时停止执行,结果返回后再恢复。因此用户只需要确认一次,后续推进由持久化状态和内部调度驱动。

  1. beginRun 为什么需要传入 notificationId?

参考回答:可以把它理解为一次性执行凭证。系统只有在收到与当前任务匹配的续跑事件时,才允许启动新一轮执行;已经消费过的事件、旧计划产生的事件或者重复投递的事件都会被拒绝。这样即使通知发生重复或乱序,也不会轻易启动两个相同的执行轮次。通知消费和任务状态更新还需要原子完成,避免通知已消费但任务没有真正进入运行状态。

  1. version、runId 和 runLeaseUntil 分别解决什么问题?

参考回答:任务版本解决并发覆盖问题,保证只有基于最新状态的请求才能保存;运行轮次标识用于区分当前执行和历史执行,避免上一轮的迟到结果污染新一轮;运行租约则解决服务异常退出后任务一直显示“执行中”的问题。租约过期后,系统会重新判断任务应该安全重试、等待已有异步结果,还是进入结果核对。三者分别处理并发、隔离和故障恢复。

  1. Redis 中的并发更新是怎么保证原子的?

参考回答:保存任务时会同时校验任务版本和当前运行轮次,只有二者都符合预期才允许更新。具体通过 Redis Lua 脚本,把任务内容、运行占用、活动任务索引和续跑通知放在同一次原子操作中完成。如果版本已经变化,说明有其他请求率先更新了任务,当前请求就会失败并重新读取最新状态。这样可以避免先写任务、再写通知过程中出现一半成功、一半失败。

  1. 批次任务怎样避免重复项、漏项和跳项?

参考回答:批次执行位置由服务端维护,而不是让模型自行描述。每一项执行前先记录当前要处理的最小操作单元,调用业务接口后再记录真实结果,结果保存成功后才推进到下一项。如果业务调用失败且确定没有产生副作用,就保留当前位置等待重试;如果是否成功无法确认,就停在当前位置先核对结果。这样可以尽量避免跳过数据,也不会因为模型重试而直接重复整批操作。

  1. 为什么每轮不把整个批次全部执行完?

参考回答:大批量操作可能超过模型单轮时间、服务请求超时或者下游接口承载能力。按固定大小拆分后,每轮只处理有限数量的数据,可以控制执行时间、上下文长度和下游压力。每个小批次也是一个恢复点,用户暂停或者服务异常时,最多只需要处理当前最小单元的不确定性。剩余数据由续跑机制自动进入下一轮。

  1. recordOutcome 为什么不允许模型直接提交任意状态?

参考回答:模型输出具有不确定性,如果允许它直接把任务修改为完成、失败或跳到任意步骤,就可能绕过计划和确认流程。因此模型只能上报有限类型的执行结果,例如取得进展、完成步骤、可重试失败或者结果不确定。最终应该进入什么状态、是否推进步骤、是否创建续跑事件,都由服务端结合真实任务状态计算。这样把业务状态机控制权留在确定性代码中。

  1. 暂停、继续和停止分别如何实现?

参考回答:暂停会保存当前执行位置和暂停前的阶段,同时停止启动新的执行轮次;继续时重新读取最新任务状态,判断应该继续正常执行、恢复异步监控,还是先核对未确认的操作结果。停止会把任务设置为终止状态,并清理后续调度,使系统不再执行剩余步骤。需要注意,暂停和停止只能阻止尚未发起的操作,无法撤销已经到达下游系统的请求。

  1. 重试失败和副作用结果不确定有什么区别?

参考回答:如果能够确定请求没有发送出去,例如参数校验失败或执行前就发生异常,那么重试不会造成重复写入,可以进入普通重试流程。如果请求已经发送,但因为超时或断连没有收到结果,就不能判断下游是否执行成功,此时直接重试可能产生重复数据。系统会把这种情况转入结果核对流程,优先通过查询接口、业务唯一键或者异步任务编号确认真实结果。

  1. 服务重启后,任务是如何恢复的?

参考回答:任务恢复依赖 Redis 中保存的计划、当前步骤、批次位置、操作记录、异步任务信息和待处理通知,而不依赖 JVM 内存。服务恢复后,在任务再次被加载或续跑事件被处理时,会检查上一次执行是否已经结束以及运行租约是否过期。如果没有发起写操作,可以从当前位置重试;如果已经发起写操作,则先核对结果;如果已经提交异步任务,则恢复对原任务的监控,而不是重新提交。

  1. 如果服务在业务接口成功后、保存 checkpoint 前宕机会怎样?

参考回答:这属于最典型的分布式不确定窗口:下游可能已经成功,但本地没有保存成功结果。如果直接重试,可能产生重复写入;如果直接标记成功,又没有证据。因此系统会在调用前先保存稳定的操作标识,恢复后根据这个标识查询下游结果。下游支持幂等键或结果查询时可以自动处理,否则就需要停止自动执行并转人工确认。

  1. 异步任务是怎样与父任务关联的?

参考回答:提交异步任务时,系统会同时保存外部任务编号以及它所属的父任务、计划版本、步骤和批次位置。异步结果返回后,必须验证这些关联仍然有效,才能推动父任务继续执行。这样可以避免旧计划产生的迟到结果误更新新计划,也能在服务重启后继续查询原来的异步任务,而不是重复创建一个新任务。

  1. 怎样防止任务无限自动续跑?

参考回答:系统会判断每轮执行是否产生了真实进展,例如步骤是否推进、批次位置是否变化或者是否获得了新的结果。如果连续多轮没有有效进展,就停止自动续跑,转为等待用户重试或人工处理。同时任务还有整体执行期限,超过期限后不再发起新的写操作。这样可以避免模型或下游异常导致任务持续空转。

四、架构边界与高级追问

  1. 为什么选择 Redis,而不是数据库或 MQ?

参考回答:长任务运行期间需要频繁读取和更新状态,还要完成版本校验、运行占用和续跑通知的原子更新,Redis 的低延迟和 Lua 脚本比较适合这种运行态协调。数据库更适合长期审计和复杂查询,MQ 更适合独立的可靠消息投递,但单独使用它们仍需要解决任务状态与消息之间的一致性。当前方案以 Redis 管理运行状态,如果业务要求更强的持久性,可以把关键操作流水和最终结果额外写入数据库。

  1. 这套机制能保证 exactly-once 吗?

参考回答:不能宣称跨系统严格的 exactly-once。平台内部可以通过版本控制、执行位置、稳定操作标识和结果记录降低重复执行概率,但如果下游已经写成功、本地却因为网络问题没有收到响应,平台无法凭自身状态证明下游结果。真正实现端到端幂等,需要下游接受并保存统一的幂等键,或者提供可靠的结果查询接口。当前更准确的描述是“可恢复执行和重复风险控制”。

  1. 执行守卫在长任务里解决了什么问题?

参考回答:提示词只能告诉模型应该怎么做,但不能保证模型一定遵守,特别是在模型重试、上下文压缩或者工具并行调用时。因此系统会在工具真正执行前检查任务是否已经创建、计划是否已经确认、当前是否具有有效执行资格,以及写操作是否已经完成必要准备。在结果核对阶段还会禁止调用写工具,防止把“查询结果”错误地变成“再次执行”。服务层会再做一次状态校验,形成双层保护。

  1. 你认为当前方案还有哪些改进空间?

参考回答:第一,可以推动所有写接口统一支持幂等键,让平台生成的操作标识真正贯穿下游系统。第二,可以把关键操作流水写入数据库,增强审计、追踪和长期恢复能力。第三,需要补充 Redis 故障、重复通知、网络超时和服务宕机等故障注入测试。第四,可以增加任务积压、租约超时、结果核对数量和无进展轮次等监控指标,方便及时发现异常任务。

2. 为计划内写操作设计稳定操作标识和结果记录机制,并通过执行守卫限制未创建任务、未提交计划或未经确认时执行批量写操作,降低模型重试和网络异常导致的重复执行及越权调用风险

2.1 具体实现方式

一、问题背景

Agent 与普通后端接口不同:模型可能因为超时、上下文续跑或服务重启重复生成同一个 Tool 调用;网络异常时,也无法仅根据“没有收到响应”判断请求是否已经到达下游。如果直接重试写操作,可能重复创建卡片、重复提交图片任务或重复回滚生产任务。

因此,这部分工作的核心不是简单增加一个重试,而是把写操作改造成受服务端状态机约束的协议:

1
2
3
4
5
创建任务 -> 提交完整计划 -> 用户确认计划
-> 开启受租约保护的运行轮次
-> 为最小写操作生成 operationId
-> 执行业务 Tool
-> 记录确定结果或进入只读核对

模型负责选择动作和描述结果,服务端负责判断“现在是否允许执行、是否可以重试、下一状态是什么”。

二、整体组件

主要实现分为五层:

组件 职责
AgentTaskCheckpoint 保存计划版本、步骤、游标、运行租约、操作标识、外部任务引用和失败信息
AgentTaskCheckpointService 生成操作标识、校验运行上下文、记录结果并计算状态迁移
AgentTaskCheckpointStore 使用 Redis Lua CAS 原子保存 checkpoint、当前运行轮次和续跑通知
LongTaskExecutionGuardMiddleware 在 Tool 真正执行前拦截未规划、未确认、越过运行边界的调用
ToolExecutionConfirmationCoordinator 对特定高风险 Tool 的具体参数进行二次确认和一次性放行

三、稳定操作标识

写操作开始前,模型或确定性批处理协调器必须调用 prepareOperation。核心代码位于 AgentTaskCheckpointService.java:

1
2
3
4
5
6
7
8
9
10
11
String cursor = normalizeOptional(stepCursor, MAX_PROGRESS_BYTES);
String cursorDigest = sha256(cursor == null ? "" : cursor);

checkpoint.setOperationId(sha256(lengthPrefix(
checkpoint.getTaskId(),
String.valueOf(checkpoint.getPlanVersion()),
String.valueOf(checkpoint.getCurrentStepNo()),
cursorDigest)));

checkpoint.setOperationRunId(runId);
checkpoint.setOperationCursorDigest(cursorDigest);

操作标识可以概括为:

1
operationId = SHA-256(taskId + planVersion + stepNo + SHA-256(stepCursor))

这里使用带长度前缀的编码,而不是直接拼接字符串,避免字段边界歧义。runId 没有进入 operationId,因此同一个计划、步骤和批次游标在重试或服务恢复后仍对应同一个稳定操作;同时通过独立的 operationRunId 校验本次操作属于哪个运行轮次。

prepareOperation 还会校验:任务必须处于 RUNNING、运行租约有效、当前是 NORMAL 而不是核对轮次、步骤声明了 sideEffect=true、没有未闭合 operation、任务没有过期。任一条件不满足,写操作都不能开始。

四、Prepare、Execute、Record 三段式协议

完整执行过程如下:

  1. submitPlan 保存完整步骤,增加 planVersion,并把状态切换为 AWAITING_PLAN_CONFIRMATION。
  2. 用户确认后,服务端令 confirmedPlanVersion = planVersion;计划修改会产生新版本,旧确认自动失效。
  3. beginRun 校验续跑通知 ID、续跑类型和计划版本,生成有过期时间的 runId。
  4. prepareOperation 为当前最小操作单元生成 operationId。
  5. 业务 Tool 在真正发送 HTTP、RPC 或 MCP 请求前调用 markSideEffectAttempted。
  6. 调用结束后只能通过 recordOutcome 提交受控结果,最终状态由服务端计算。

批处理由 DeterministicBatchCoordinator.java 按项推进:

1
2
3
4
5
6
7
8
9
checkpoint = checkpointService.prepareOperation(..., currentCursor);
itemResult = executor.execute(items.get(index), runtimeContext, batchContext);

index++;
checkpoint = checkpointService.recordOutcome(
..., stepCompleted ? "STEP_COMPLETED" : "STEP_PROGRESS",
aggregateSummary(...),
stepCompleted ? null : cursor(stepNo, index),
...);

只有当前项取得真实返回后,游标才会向下一项移动。异常时,协调器根据 sideEffectAttempted 自动选择 RETRYABLE_FAILURE 或 UNCERTAIN_SIDE_EFFECT,并停止继续推进当前批次。

五、结果记录和异常分类

recordOutcome 只接受固定结果类型,模型不能直接指定 checkpoint 状态:

结果类型 含义与处理
STEP_PROGRESS 当前批次有进展;要求新游标非空且必须向前推进
STEP_COMPLETED 当前高层步骤完成;清理当前 operation 并推进下一步骤
RETRYABLE_FAILURE 业务请求未发出或明确失败,可以安全重试
UNCERTAIN_SIDE_EFFECT 请求已经发出但结果未知,保留 operation 并进入核对流程
PLAN_REVISION_REQUIRED 计划需要修改;存在未核对 operation 时禁止修改
TASK_COMPLETED 仅计划最后一步允许结束整个任务
TASK_FAILED 明确结束任务,不再自动续跑

关键校验位于 AgentTaskCheckpointService.java:

1
2
3
4
5
6
7
8
9
10
if (successOutcome && step.isSideEffect()
&& checkpoint.getOperationId() == null) {
throw new IllegalStateException("副作用步骤完成前必须先准备操作标识");
}
if (outcome == UNCERTAIN_SIDE_EFFECT && preparedButNotAttempted) {
throw new IllegalStateException("副作用尚未发起,应记录可重试失败");
}
if (outcome == RETRYABLE_FAILURE && operationAlreadyAttempted) {
throw new IllegalStateException("必须先核对副作用结果,不能直接重试");
}

Checkpoint 不保存无限增长的通用结果列表,而是保存恢复执行所需的最小状态:progressDigest、lastProgressFingerprint、stepCursor、lastFailureType/lastFailureDigest、operationRef、asyncTaskRef 和 asyncResultStatus。进度指纹还能拒绝完全重复的进度提交。

六、网络异常和迟到结果

图片生成是完整接入该协议的例子。Tool 提交前先捕获父任务快照,快照包含:

1
2
taskId + planVersion + stepNo + operationId
+ operationCursorDigest + operationRunId + monitorGeneration

请求成功后,将下游返回的 openSessionId 保存为 operationRef。绑定时必须与父快照全部匹配,防止旧请求结果写入新计划或新步骤。

如果请求已经返回,但当前 Agent 请求刚好关闭,recordLateOperationReference 会通过 Redis CAS 尝试补写该引用,并把任务转入 MONITOR_RECOVERY。恢复时只允许使用已保存的 openSessionId 重建监控,不允许再次提交图片任务。相关实现见 ImageGenerateSubmitTool.java。

七、执行守卫

LongTaskExecutionGuardMiddleware.java 在 next.apply(input) 之前检查 Tool 调用,因此约束不是仅靠 Prompt 提醒。

守卫主要限制:

  • 多项任务必须先加载长任务工作流、创建任务、提交完整计划并等待整体确认。
  • 任务已经创建后,下一次调用只能提交该任务的计划。
  • 计划保存后不能继续调用业务 Tool,只能向用户展示计划并等待确认。
  • 任务状态 Tool 和业务 Tool 不能在同一批执行。
  • 单次 reasoning 最多执行一个未声明为并行安全的业务调用。
  • 图片提交必须独占批次,并且副作用步骤必须先准备 operationId。
  • 核对轮次只能调用显式声明为 reconciliation-safe 的只读核对或监控恢复能力。
  • 超出本轮执行预算后,禁止开启新运行轮次和新业务写操作。
  • 系统续跑通知的 ID、类型和目标任务必须与 checkpoint 一致,旧通知不能启动新轮次。

守卫在 supervisor 和 domain 两层生效:前者限制跨业务域委托,后者限制叶子 Tool。Tool 的 planning-safe、reconciliation-safe、parallel-safe 等属性由 DomainAgentSupport 统一组装。

八、高风险 Tool 的参数级确认

完整计划确认解决的是“用户是否同意执行整个计划”;导航卡创建、生产任务回滚等高风险能力还增加了第二层确认,即用户是否同意执行这一组具体参数。

Tool 通过 executionConfirmationRisk 声明风险分类。首次调用不会真正执行,而是保存:

1
domain + toolName + riskCategory + canonicalArguments

并把长任务从 RUNNING 原子切换为 AWAITING_TOOL_CONFIRMATION。用户确认后,Redis Lua 将确认记录从 PENDING 原子 claim 为 EXECUTING。真正执行时使用一次性 bypass:

1
2
3
4
5
return domain.equals(actualDomain)
&& toolName.equals(actualToolName)
&& riskCategory.equals(actualRiskCategory)
&& canonicalArguments.equals(actualCanonicalArguments)
&& consumed.compareAndSet(false, true);

因此确认只能用于原 Tool、原风险类型和原参数,而且只能消费一次。执行结果会保存为 resultState/resultText/resultJson;恢复轮次消费该结果时,守卫禁止再次调用风险 Tool。

九、Redis 并发保护

Checkpoint 更新使用 expectedVersion 加 Redis Lua CAS。Lua 脚本在同一次原子操作中校验旧版本,并更新 checkpoint、活动任务集合、current run 和续跑通知。旧模型响应、重复续跑请求或并发控制命令拿着过期版本写入时,会直接失败并要求重新读取状态。

确认记录也通过 Lua 原子完成注册、PENDING -> EXECUTING 抢占和最终结果保存,避免两个确认请求同时取得执行权。

十、能力边界

这套实现保证的是平台内部的稳定关联、防止盲目重放和可恢复核对,不应表述为“所有外部写操作严格 exactly-once”。当前 operationId 并没有统一作为下游接口的幂等键传递;如果下游在请求成功后断网,平台仍需要依赖 operationRef、查询接口或业务流水核对真实结果。

另外,执行守卫防止的是任务协议越权,例如未规划、未确认、无运行租约或在核对轮次执行写操作。用户是否拥有某项业务权限,仍由 Tool 可见性、权限码和 Tool 自身权限校验负责。

面试时可以总结为:我把 Agent 写操作从模型自由调用改造成服务端约束的 Prepare-Execute-Record 协议,用计划版本、步骤和游标生成稳定 operationId;再结合副作用发出标记、结果分类、外部操作锚点、Redis CAS 和执行守卫,使请求未发出时可以安全重试、请求结果未知时只能先核对,从而降低模型重试、网络异常和并发续跑带来的重复写入与协议越权风险。

2.2 提问

2.2.1 自己想到的问题

2.2.2 AI想到的问题

  1. 这套机制主要解决什么问题?

参考回答:它主要解决两类风险。第一类是模型重试、请求超时或服务重启后,系统可能重复调用写接口;第二类是模型没有完成建任务、提交计划和用户确认,就直接执行批量写操作。我的处理思路是:写操作前先建立稳定身份,执行后保存结果;同时在工具调用层增加强制校验,把不符合流程的调用拦截在业务接口之前。

  1. 整体执行流程是什么?

参考回答:完整流程是先创建任务,再保存完整计划并让用户确认。任务进入合法运行阶段后,每个写操作都要先登记本次要执行的逻辑单元,再调用业务接口,最后根据真实结果记录成功、可重试失败或者结果不确定。只有结果保存成功后,系统才会推进批次位置或进入下一步骤。

  1. 为什么既需要稳定操作标识,又需要结果记录?

参考回答:操作标识只能说明“准备执行哪个逻辑操作”,不能证明这个操作是否已经成功。结果记录用于保存业务接口的真实返回、执行进度和下一步处理方式。两者结合后,系统恢复时才能判断应该直接继续、重新执行,还是先查询下游结果。如果只有操作标识而没有结果,任务仍然会停留在待核对状态。

  1. 什么叫“稳定操作标识”?

参考回答:稳定指的是同一个任务、同一个计划版本、同一个步骤和同一个批次位置,无论因为模型重试还是服务重启,计算出的操作标识都保持一致。不同计划版本、不同步骤或者不同批次项,标识则不同。这样系统可以区分“同一个操作的再次尝试”和“一个新的业务操作”。

  1. 稳定操作标识是如何生成的?

参考回答:代码会把任务身份、当前计划版本、步骤编号和批次位置组合起来,规范化后计算摘要。批次位置不会直接使用模型提供的任意文本,而是基于服务端保存的执行位置。计划变化后版本也会变化,因此旧计划和新计划中的操作不会错误地复用同一个标识。

  1. 为什么操作标识中不直接包含每次执行的运行轮次?

参考回答:因为运行轮次每次重试都会变化,如果把它作为稳定标识的一部分,同一个逻辑操作重试时就会生成新标识,失去防重意义。当前实现把“逻辑操作身份”和“本次执行轮次”分开保存:前者用于识别同一个业务操作,后者用于判断当前请求是否仍属于有效执行轮次。

  1. 为什么必须在调用业务接口之前生成操作标识?

参考回答:如果先调用业务接口,再保存操作标识,那么服务在两者之间宕机时,本地完全没有任何记录,却可能已经在下游产生了写入。提前保存后,即使调用过程中发生异常,系统至少知道当前存在一个尚未闭合的写操作,可以阻止盲目重试,并尝试通过下游业务标识查询真实结果。

  1. 准备操作时会做哪些校验?

参考回答:系统会检查当前任务是否处于有效运行阶段、当前步骤是否确实包含写操作、任务是否已经超过执行期限,以及是否存在上一次尚未核对完成的操作。如果已经有一个未闭合操作,就不能再准备新的写操作,必须先完成结果核对。这避免了多个不确定副作用同时存在,导致恢复逻辑无法判断。

  1. 写操作执行后记录哪些内容?

参考回答:主要记录当前操作对应的业务结果、批次执行位置、进展摘要、下游返回的业务引用以及下一步动作。对于异步任务,还会保存外部任务编号和轮询信息。这些信息的目标不是保存完整业务响应,而是保存足够支持恢复和核对的最小事实集。

  1. 为什么只允许记录有限的结果类型?

参考回答:模型不能直接决定任务应该进入任意状态,否则可能跳过步骤、提前结束任务或者绕过确认。当前实现只允许模型报告几类受控结果,例如取得进展、步骤完成、可重试失败、结果不确定、需要修订计划以及任务结束。服务端再结合当前任务状态计算真正的下一状态。

  1. 如何区分“可以重试”和“结果不确定”?

参考回答:关键是判断业务请求是否已经真实发出。如果在参数校验、权限检查或者调用前准备阶段就失败,可以认为没有产生副作用,允许安全重试。如果请求已经发给下游,但因为网络超时、连接中断或者响应解析失败没有拿到明确结果,就属于结果不确定,不能直接重试。

  1. 代码如何知道业务请求是否已经发出?

参考回答:具体业务工具会在真正调用 HTTP 或 RPC 接口之前标记“副作用已经开始尝试”。例如导航卡创建、生产任务回滚和图片提交等工具都在下游调用边界设置这个标记。发生异常时,上层根据该标记进行分类:尚未发起的按普通失败处理,已经发起但没有确定结果的进入核对流程。

  1. 网络超时后为什么不能直接重试?

参考回答:网络超时可能发生在下游处理之前,也可能发生在下游已经写成功、但响应返回失败之后。本地无法仅凭超时判断是哪一种情况。如果直接重试,可能创建重复数据或者重复修改业务状态。因此系统会保留操作标识和已有下游引用,先调用只读查询能力核对结果。

  1. 结果不确定后系统如何继续?

参考回答:任务会停止正常写入流程,切换到结果核对阶段。在这个阶段只允许读取任务状态、查询下游结果或者恢复已有异步任务监控,不允许再次执行写工具。如果确认原操作已经成功,就补录结果并继续计划;确认没有执行时,才能重新发起;仍然无法判断时,则等待人工处理。

  1. 如果业务调用成功,但保存结果失败了怎么办?

参考回答:这种情况不能对用户简单返回失败并允许再次执行,因为下游写操作可能已经完成。系统会保留已准备的操作信息,并尽量记录下游返回的业务引用。后续恢复时通过该引用核对真实结果;如果引用也没有保存成功,就进入更保守的不确定状态,禁止自动重复执行。

  1. 异步写操作如何记录结果?

参考回答:像图片生成这种操作,提交成功后不会立即产生最终结果。代码会先保存下游返回的任务编号,并把它与当前父任务、步骤和批次位置绑定,然后启动结果监控。即使监控进程中断,恢复时也只会重新监控原来的外部任务,不会再次提交一个新的生成任务。

  1. 为什么不能只通过 Prompt 要求模型遵守流程?

参考回答:Prompt 只能提供行为指导,不能成为副作用安全边界。模型可能因为上下文压缩、重试、错误推理或者并行工具调用而跳过流程。因此项目在 Tool 真正执行前增加了代码级守卫,只要任务状态、确认状态或运行条件不满足,就直接拒绝工具调用。

  1. 未创建任务时,守卫如何阻止批量写操作?

参考回答:系统会结合用户请求中的对象数量、编号列表、多图片输入以及“逐项处理”等语义,识别明显的多对象任务。一旦判定需要受控批量执行,当前请求只能先加载长任务工作流、完成必要的只读规划查询,然后创建任务。此时如果模型直接调用业务写工具,守卫会拒绝执行。

这里也存在一个边界:自然语言识别具有启发式性质,不能覆盖所有表达方式。因此代码还会结合已注册业务工具、调用数量和是否属于安全并行工具做第二层判断。

  1. 任务已经创建但计划还没提交时,会发生什么?

参考回答:任务创建成功后,守卫会把下一步限制为提交该任务的完整计划。模型不能继续加载无关能力、执行写操作或者只在自然语言中描述一个没有持久化的计划。只有计划经过服务端校验并成功保存,系统才会向用户展示并请求确认。

  1. 计划已经提交但用户尚未确认时,为什么仍然不能执行?

参考回答:计划提交只代表系统保存了执行方案,不代表用户已经授权执行。计划保存后,当前轮次会进入收口阶段,守卫禁止继续调用业务工具,只允许把完整计划展示给用户。用户确认后,系统还会核对确认的是不是最新计划版本,防止用户确认旧计划、系统执行新计划。

  1. 计划已经确认后,模型是不是就可以随意调用写工具?

参考回答:不可以。计划确认后还要由系统启动有效执行轮次,并限定当前唯一允许执行的步骤。对于有副作用的步骤,还需要先完成操作准备;如果任务已经暂停、取消、过期或者执行轮次失效,业务工具仍然会被拒绝。确认是必要条件,但不是唯一条件。

  1. 为什么不允许任务状态工具和业务工具在同一批调用?

参考回答:因为模型在发起业务调用时,必须基于任务状态工具返回的真实结果进行决策。如果两个调用同时提交,模型实际上还没有看到最新任务状态,就已经开始执行业务操作。项目要求先读取或更新任务状态,下一次推理再发起业务调用,从而形成明确的状态屏障。

  1. 为什么限制一次只能调用一个非并行安全的业务工具?

参考回答:多个写工具并行执行后,如果其中一部分成功、一部分超时,恢复时很难判断每个操作的真实结果,也难以确定批次位置。当前守卫默认只允许一个非并行安全调用,只有明确登记为可安全并行的只读或独立能力才能并行,并且还有数量上限。这种限制牺牲了一部分吞吐量,但显著降低了副作用恢复的复杂度。

  1. 结果核对阶段如何防止再次写入?

参考回答:结果核对会使用单独的运行模式,守卫只允许任务读取、结果查询和监控恢复等白名单操作。普通写工具和不匹配当前恢复类型的查询都会被拒绝。这样即使模型把“核对之前的结果”误解成“重新执行一次”,调用也无法到达业务接口。

  1. 整体计划确认和高风险工具确认有什么区别?

参考回答:整体计划确认表示用户同意执行完整任务方案;高风险工具确认针对的是某一次具体调用及其参数快照。对于需要单独确认的敏感工具,用户确认后只能执行保存下来的那份参数,不能由模型临时修改。重复确认会被识别为已经处理,不会再次执行工具。

  1. 模型重试或者两个请求并发执行时,如何避免重复推进?

参考回答:任务状态更新采用乐观并发控制,每次写入都必须基于读取时的版本。Redis 脚本会原子校验任务版本、当前执行轮次和相关操作信息,然后再更新任务和续跑通知。两个请求并发时,只有一个能保存成功,另一个必须重新读取状态,不能用旧结果覆盖新进度。

  1. 服务端批次协调器和执行守卫是什么关系?

参考回答:执行守卫负责回答“当前允不允许调用”,批次协调器负责回答“当前应该执行哪一项”。协调器从服务端保存的位置取出下一项,先准备操作,再调用业务执行器,最后保存结果。失败时不会推进位置,所以恢复后仍然处理当前项;成功保存后才进入下一项。

  1. 这套机制能保证所有写操作 exactly-once 吗?

参考回答:不能宣称跨系统严格 exactly-once。平台内部可以稳定识别逻辑操作、拒绝旧请求并保留结果核对信息,但如果下游成功而本地没有收到响应,最终仍然需要下游提供幂等键或者结果查询能力。更准确的说法是:系统显著降低重复执行和越权调用风险,并对不确定副作用提供可恢复的处理路径。

  1. 这套方案最大的取舍是什么?

参考回答:最大的取舍是用一定的执行复杂度和串行限制换取副作用安全。写操作前后都要持久化状态,部分工具不能并行,结果不确定时还需要额外核对,因此吞吐量会受到影响。但对于运营后台的批量写场景,可恢复性和避免重复修改通常比极致吞吐更重要。

  1. 当前方案还有哪些改进空间?

参考回答:第一,可以把稳定操作标识统一透传给所有下游,让下游真正按幂等键防重;第二,为关键写操作增加数据库审计流水,增强长期追踪能力;第三,完善工具副作用属性的统一注册,减少依赖自然语言识别;第四,补充并发请求、重复确认、结果保存失败和服务宕机等故障注入测试;第五,增加不确定操作数量、守卫拒绝原因和核对任务积压等监控指标。

3. 负责业务子 Agent 配置化改造,支持通过 Paladin 动态配置系统提示词、Tool、Skill 和权限码;实现配置解析、合法性校验、热更新及 Agent 缓存失效,降低新增业务域 Agent 的代码改动成本

3.1 具体实现方式

一、改造背景

改造前,每个业务域都需要编写一个独立 Java 装配类,在代码中固定:

  • Agent 名称和系统提示词;
  • 当前域包含哪些 Tool、Skill;
  • 每项能力需要什么权限;
  • 主 Agent 应注册哪些业务子 Agent。

例如历史上的 ActivityDomainAgent、DeliveryDomainAgent 和 AiCreatorIncentiveDomainAgent 都维护了静态 ToolBinding、SkillBinding 和 Prompt。新增或调整业务域通常需要修改 Java、重新编译并发布。

改造后,业务域的“编排关系”放入 Paladin:

1
2
3
4
5
6
Paladin JSON
-> 配置解析与完整校验
-> 不可变业务域快照
-> 按用户权限裁剪 Tool/Skill
-> 动态组装子 Agent
-> 注册为主 Agent 的 delegate Tool

如果所需 Tool、Skill 已经部署,新增业务域或调整能力组合只需要修改配置;只有新增业务实现时才需要开发新的 Java Bean。

二、整体架构

组件 主要职责
BusinessDomainAgentConfig 定义 Paladin JSON 的字段结构
DomainAgentConfigParser 解析配置并完成合法性、安全性校验
AgentCapabilityCatalog 维护 capabilityId -> Tool/Skill Bean 白名单
ToolBinding / SkillBinding 保存能力实现、权限码和可见性
DomainAgentSupport 按权限筛选能力并构建 AgentScope ReActAgent
AgentFactory 监听 Paladin、发布配置快照、构建和缓存主 Agent
UserPermissionService 根据用户和请求权限范围计算有效权限

主要入口位于 AgentFactory.java。

三、Paladin 配置结构

业务域配置键为:

1
ogv_agent_business_domain_config

字段模型定义在 BusinessDomainAgentConfig.java。配置示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"domains": [
{
"enabled": true,
"agentCode": "delivery-domain-agent",
"description": "投放业务域 Agent。",
"systemPrompt": "你是 OGV 投放域专家,只处理当前已接入的投放能力。",
"tools": [
{
"capabilityId": "deliveryProjectQueryTool",
"permissionCodes": ["delivery:project:query"]
}
],
"skills": [
{
"capabilityId": "imageGenerationWorkflowSkill",
"permissionCodes": ["ai:image"]
}
]
}
]
}

agentCode 会自动派生主 Agent 中的委托 Tool 名:

1
delivery-domain-agent -> delegate_to_delivery_domain

配置中的 systemPrompt 只负责角色定位、业务边界和行为基调。Tool 用法来自 @Tool 描述,具体业务限制由 Tool 代码校验,副作用确认由中间件处理,避免 Prompt 与真实能力发生漂移。

四、能力目录与白名单

配置不能填写任意 Java 类名。Spring 启动时会将所有 OgvToolBase、OgvSkillBase Bean 注入 AgentCapabilityCatalog.java:

1
2
3
4
5
6
7
8
public OgvToolBase requireTool(String capabilityId) {
OgvToolBase tool = toolsById.get(capabilityId);
if (tool == null) {
throw new IllegalArgumentException(
"未知 Tool capabilityId: " + capabilityId);
}
return tool;
}

目录同时建立正向和反向索引:

1
2
capabilityId -> OgvToolBase / OgvSkillBase
实现类型 -> capabilityId

因此 Paladin 只能组合已经部署并注册的能力,不能通过配置反射加载任意类。系统还会检查同一种实现类型不能对应多个 capabilityId。

当前代码实际使用 Spring Bean 名作为 capabilityId。若使用默认 @Component 命名,DeliveryProjectQueryTool 对应 deliveryProjectQueryTool。因此 capabilityId 应被视为稳定配置契约;如果以后重命名 Java 类,最好显式固定 Bean 名,否则可能同时影响 Paladin 配置。

五、配置解析和合法性校验

DomainAgentConfigParser.java 不只是反序列化 JSON,而是整个动态配置的安全边界。

核心解析过程:

1
2
3
4
5
6
7
8
9
10
11
BusinessDomainAgentConfig root =
objectMapper.readValue(rawConfig, BusinessDomainAgentConfig.class);

for (DomainConfig domain : root.getDomains()) {
if (!domain.getEnabled()) {
continue;
}
ConfiguredDomain configured = parseDomain(domain);
result.add(configured);
}
return List.copyOf(result);

Parser 会完成以下校验:

  • 配置不能为空,domains 必须存在且最多 64 个;
  • enabled 必须显式配置;
  • agentCode 必须符合 xxx-domain-agent 格式;
  • 禁止使用主 Agent、公共域 Agent 等保留名称;
  • description、systemPrompt 必填并限制长度;
  • tools、skills 必须是数组,且不能同时为空;
  • capabilityId 必须能在能力目录中找到;
  • Tool 必须声明有效的 @Tool 方法;
  • Skill 必须有可读取的 classpath 资源或实现 RuntimeOgvSkill;
  • 每项 USER 能力必须绑定非空、格式合法的权限码;
  • 同一个域不能重复绑定同一 Tool 或 Skill;
  • Tool 名、Skill ID、Skill Action 名不能发生冲突;
  • 不同域不能有重复的 agentCode 或委托 Tool 名;
  • 业务域禁止引用 SYSTEM Tool 和 SYSTEM Skill。

只有全部域都校验成功,才会生成不可变的 ConfiguredDomain 列表,不存在只更新一半域的情况。

六、SYSTEM 能力隔离

ToolBinding 和 SkillBinding 将能力分为两种可见性:

1
2
USER   普通业务能力,必须绑定 permissionCodes
SYSTEM 平台控制能力,不依赖用户权限,但不能由业务配置引用

例如长任务 checkpoint、任务控制和部分平台 Skill 属于系统协议。如果允许 Paladin 业务域随意引用这些能力,就可能绕过主 Agent 的执行约束。

Parser 会通过 AgentCapabilityCatalog.isSystemTool/isSystemSkill 在配置生效前直接拒绝。这是装配期隔离,而不是依赖 Prompt 告诉模型“不要调用”。

七、按用户权限动态装配 Tool 和 Skill

配置中的 permissionCodes 表示该能力允许哪些权限使用,同一能力配置多个权限码时采用 OR 语义。

DomainAgentSupport.java 在组装阶段完成过滤:

1
2
3
4
5
if (binding.getVisibility() == CapabilityVisibility.USER
&& (permission == null
|| !permission.hasAnyAccess(binding.getPermissionCodes()))) {
continue;
}

没有权限的能力不会注册到 Toolkit,也不会出现在 Agent 的系统提示词或能力描述中。因此模型从一开始就看不到未授权 Tool,而不是等调用时才拒绝。

Tool 过滤后注册到 AgentScope Toolkit;Skill 则解析为 RegisteredSkillCapability,通过 SkillFilter.only(skillIds) 限定当前 Agent 可加载的 Skill。若当前用户一个 Tool、Skill 都不可见,则该业务域不会注册到主 Agent。

系统提示词最终由三部分组成:

1
2
3
Paladin 中的域 systemPrompt
+ 平台统一执行规则
+ 当前用户真正可见的 Tool/Skill 能力清单

这保证 Prompt 中展示的能力和 Toolkit 中实际存在的能力一致。

八、用户权限来源

用户基础权限由另一份 Paladin 配置 ogv_agent_user_permission_config 管理。UserPermissionService 根据 userId 和 ANYONE 配置计算权限集合。

如果请求还携带 permissionScope,最终权限取交集:

1
effectivePermissions = basePermissions ∩ permissionScope

因此请求只能缩小权限,不能通过参数声明自己原本没有的权限。

部分 Tool/Skill 还会读取权限项中的 bizConfigs,通过 bindRuntimeContext 创建用户相关的运行时能力。如果所需业务配置不存在,该能力即使权限码匹配,也可以不注册。

九、Paladin 热更新

AgentFactory.java 在 @PostConstruct 阶段注册监听:

1
2
3
4
5
6
7
8
9
configSource.watch(
BUSINESS_DOMAIN_CONFIG_KEY,
this::onBusinessDomainConfigChanged,
Runnable::run);

String rawConfig = configSource
.getNullableString(BUSINESS_DOMAIN_CONFIG_KEY)
.orElse("");
refreshBusinessDomainConfig(rawConfig);

收到配置变更后,更新过程由写锁保护:

1
2
3
4
5
6
List<ConfiguredDomain> domains = domainConfigParser.parse(rawConfig);
DomainConfigSnapshot snapshot =
createDomainConfigSnapshot(domains, rawConfig);

domainConfigSnapshot = snapshot;
supervisorAgentCache.invalidateAll();

执行顺序非常关键:先解析、再构造完整新快照、最后原子替换并清缓存。若解析或校验失败,赋值和缓存失效都不会执行,线上继续使用旧的业务域快照。

启动阶段则采用 fail-fast:缺少 ConfigSource、监听注册失败或初始业务域配置非法时,应用启动失败,避免服务以不完整 Agent 拓扑运行。

十、Agent 缓存与失效

主 Agent 使用 Caffeine 缓存:

1
2
3
maximumSize       = 256
expireAfterAccess = 6 小时
cache key = 当前有效权限集合的 MD5 指纹

用户权限集合先过滤为“配置中实际绑定过的权限”,排序后计算指纹。权限完全相同的用户可以复用同一套 Agent 装配结果,避免为每个 userId 重复创建 Agent。

当前配置版本没有直接拼入缓存 key,而是通过以下方式保证一致性:

  1. createSupervisor 在读锁内读取快照并访问缓存;
  2. 配置更新在写锁内替换快照;
  3. 替换成功后调用 supervisorAgentCache.invalidateAll();
  4. 下一次请求基于新快照重新构建 Agent。

读写锁避免出现“更新线程刚清完缓存,另一个线程又把旧快照 Agent 放回缓存”的竞态。

用户权限配置变化时,同样在写锁内刷新权限快照并清空 supervisor 缓存。userFingerprintCache 只缓存“权限集合到指纹”的纯计算结果,无需随业务配置失效。

十一、新请求如何生成 Agent

新请求调用 createSupervisor 后:

  1. 读取当前 DomainConfigSnapshot;
  2. 根据用户权限计算权限指纹;
  3. 按指纹查找 supervisor Agent 缓存;
  4. 遍历公共域和所有已启用业务域;
  5. 调用 DomainAgentSupport.createSpec 过滤 Tool、Skill;
  6. 无可见能力的域直接跳过;
  7. 为可见域创建 SubAgentTool;
  8. 将其注册为 delegate_to_xxx;
  9. 根据实际 delegate 列表生成主 Agent 路由 Prompt。

子 Agent provider 每次委托都会创建新的 ReActAgent,但其 Tool/Skill 集合和 Prompt 来自当前已校验配置及用户权限。

热更新影响后续请求,不会在一个正在执行的请求中修改现有 Agent 对象,也不会偷偷改变已经确认的长任务计划。

十二、改造前后对比

场景 改造前 改造后
新增已有能力的业务域 新建 DomainAgent Java 类并改 AgentFactory 新增 Paladin 域配置
修改系统提示词 修改代码并发布 修改 Paladin,下一请求生效
调整 Tool/Skill 组合 修改静态 Binding 修改 capabilityId 列表
调整能力权限 修改 Java 常量或 Binding 修改 permissionCodes
下线业务域 修改注册代码 enabled=false
新增全新 Tool/Skill 需要开发发布 仍需要开发发布,之后可配置组合

历史静态业务域类目前已标记 @Deprecated 且不再被生产装配链路引用;公共域 CommonDomainAgent 仍保留固定代码装配,因为它承载 SYSTEM 能力和平台协议。

十三、能力边界

这项改造实现的是“业务子 Agent 编排配置化”,不是让配置中心动态上传和执行任意代码:

  • Paladin 只能选择已经部署的 Tool/Skill;
  • 新增全新业务能力仍然需要开发、测试和发布;
  • SYSTEM 能力不能被业务域配置引用;
  • 用户基础权限不会因为配置了 permissionCode 自动获得;
  • systemPrompt 可以动态更新,但不能绕过 Toolkit 的权限过滤;
  • 业务域坏配置保留旧快照;用户权限配置解析失败则按当前实现降级为空权限映射,属于 fail-closed 策略;
  • configDigest 主要用于日志观测,不承担安全校验或缓存键职责。

3.2 提问

3.2.1 自己想到的问题

3.2.2 AI想到的问题

  1. 为什么要做业务子 Agent 配置化?

参考回答:改造前,每增加一个业务域,都需要新增一个 Java Agent 类,并在类中静态维护提示词、Tool、Skill 和权限绑定。随着业务域增加,这些类的结构高度重复,能力调整或权限变化也需要重新发布。配置化之后,已有能力之间的组合可以通过 Paladin 调整,新增业务域主要变成配置工作,Java 代码只保留通用装配逻辑和实际能力实现。

  1. 改造前后的架构有什么区别?

参考回答:改造前是每个业务域对应一个静态 Java 类,由这个类决定提示词和能力集合;改造后由 Paladin 提供业务域配置,配置解析器将其转换为统一的领域定义,再由 Agent 工厂根据当前用户权限组装对应的子 Agent。原来的活动、投放、创作激励等静态业务 Agent 类目前已经标记为废弃,公共域仍然固定在代码中,因为它包含任务状态、通知等系统能力。

  1. 哪些内容适合动态配置,哪些内容仍然保留在代码里?

参考回答:适合动态配置的是业务域开关、业务域标识、描述、基础系统提示词、Tool 和 Skill 的组合以及相应权限码。不适合配置化的是 Tool 的具体实现、下游接口调用、参数校验、风险确认规则和平台系统能力。也就是说,配置决定“组合哪些已部署能力”,代码决定“能力实际上做什么,以及是否安全”。

  1. 完整的配置加载流程是什么?

参考回答:服务启动时先注册 Paladin 配置监听器,然后读取当前业务域配置。配置经过 JSON 反序列化、字段校验、能力目录解析和权限绑定校验后,生成不可变的业务域快照。主 Agent 创建时读取这个快照,并根据当前用户实际拥有的权限过滤可见业务域和能力。Paladin 发生变化后,系统重新完成整套校验,成功后再替换快照并清理 Agent 缓存。

  1. 配置化后,新增业务域是不是完全不需要发版?

参考回答:不是。如果新业务域只是重新组合系统中已经存在的 Tool 和 Skill,可以主要通过配置完成;如果业务域需要新的下游接口或者新的业务能力,仍然要开发 Tool 或 Skill、编写测试并发布。配置化降低的是业务域装配和能力组合的改动成本,不是消除所有代码开发。

  1. 业务子 Agent 的配置中包含哪些核心内容?

参考回答:一个业务域主要包含是否启用、业务域标识、业务描述、系统提示词、Tool 列表和 Skill 列表。每项 Tool 或 Skill 通过能力标识引用已经部署的实现,同时绑定一个或多个权限码。业务域标识还会派生出主 Agent 调用该子 Agent 时使用的委托名称。

  1. 为什么配置中使用能力标识,而不是直接写 Java 类名?

参考回答:配置中心不应该感知 Java 包名和实现类,否则代码重构会直接破坏线上配置。当前代码通过能力目录把配置标识解析到已经注册的 Spring 能力实例,只允许使用系统已经部署和登记的能力。这样至少避免了配置通过反射实例化任意类,也可以在刷新阶段提前发现不存在的能力。

需要准确说明的是,当前实现中的能力标识实际来源于 Spring Bean 名称,因此仍然存在一定命名耦合。更完善的做法是通过专门注解或能力描述接口声明稳定 ID,使它不受 Java 类名和默认 Bean 命名规则影响。

  1. 为什么需要能力目录,直接从 Spring 容器按名称取 Bean 不行吗?

参考回答:能力目录不仅负责查找实例,还会建立能力标识和实现类型之间的双向索引,并检查同一种实现是否被多个标识重复注册。解析配置时,只有能力目录中的 Tool 和 Skill 才能使用。它相当于一个受控白名单,也为区分普通业务能力和系统能力提供统一入口。

  1. 配置解析器主要做了哪些基础校验?

参考回答:首先校验配置不能为空、业务域列表结构正确、业务域数量不能超过上限。然后校验每个启用域的标识格式、描述和系统提示词是否为空或超长。Tool 和 Skill 列表必须明确提供,并且不能同时为空。权限码也需要满足非空、长度和字符格式要求。

  1. 对于 Tool,还会进行哪些进一步校验?

参考回答:解析器会确认能力标识已经注册,并且不是仅供平台内部使用的系统能力。同一个业务域不能重复绑定同一种 Tool。Tool 本身还必须声明至少一个有效的工具方法,并检查工具名称是否合法,避免同一子 Agent 中出现名称冲突,否则模型无法确定应该调用哪个能力。

  1. 对于 Skill,会进行哪些进一步校验?

参考回答:Skill 必须能够被系统实际加载。静态 Skill 需要有可读取的 classpath 资源,动态 Skill 则需要实现运行时解析协议。如果 Skill 同时提供可执行 Action,还会检查 Action 是否为空、名称是否合法,以及是否与当前域中的其他 Tool 或 Action 冲突。这样可以尽量把资源缺失和注册冲突提前到配置刷新阶段。

  1. 为什么要限制业务域数量、提示词长度和权限码长度?

参考回答:配置中心内容最终会影响内存占用、Agent 提示词长度和模型上下文。如果完全不限制,错误配置可能一次创建大量业务域,或者把超长文本注入所有请求。长度和数量限制既是输入校验,也是资源保护手段,可以避免配置错误扩大为运行时故障。

  1. 为什么解析结果要转换成不可变快照?

参考回答:不可变快照可以保证一个请求在组装 Agent 时看到的是完整一致的配置,而不是正在被修改的中间状态。热更新时先在新对象中完成全部解析和校验,成功后一次替换引用。旧请求可以继续使用旧实例,新请求使用新快照,不需要在原对象上逐项修改。

  1. 用户权限是怎么计算出来的?

参考回答:系统先根据用户身份从 Paladin 权限配置中解析基础权限集合,同时支持所有用户共享的公共授权。请求还可以携带一个权限范围,但这个范围只能与用户基础权限取交集,不能增加用户原本没有的权限。因此请求参数只能缩小权限,不能通过声明权限码实现越权。

  1. 一个 Tool 绑定多个权限码时是什么语义?

参考回答:当前实现采用“拥有任意一个即可使用”的语义,也就是 OR。如果某个能力绑定了多个权限码,只要用户命中其中一个,能力就可以进入该用户的 Agent。这个语义需要在配置规范中明确,否则配置人员可能误认为必须同时拥有全部权限。

  1. 为什么要在 Agent 组装阶段过滤权限,而不是调用 Tool 时再校验?

参考回答:如果先把无权能力注册给模型,再依赖运行时拒绝,模型仍然能看到工具名称、描述和参数,并可能反复尝试调用。当前方案在组装阶段直接移除无权 Tool 和 Skill,使模型根本看不到这些能力。部分依赖具体业务配置的工具还会进行第二次运行时绑定,如果缺少必要业务配置,也不会注册到 Agent 中。

  1. 用户对某个业务域完全没有权限时怎么办?

参考回答:系统会先过滤该域下所有能力。如果过滤后 Tool 和 Skill 都为空,就不创建这个业务子 Agent,主 Agent 也不会获得对应的委托入口。这比创建一个空 Agent 后再返回“无权限”更干净,也减少了模型把请求错误路由到不可用业务域的概率。

  1. 如何防止 Paladin 配置把系统能力暴露给业务 Agent?

参考回答:平台将能力区分为普通业务能力和系统能力。任务状态、通知、临时状态等平台协议能力只允许由固定公共域装配。解析业务域配置时,如果发现引用了系统能力会直接拒绝整份配置,而不是依赖提示词告诉模型“不要调用”。

  1. 动态系统提示词会不会产生安全问题?

参考回答:会有风险,因此系统提示词不能代替权限和业务校验。当前解析器主要校验提示词非空和长度,提示词内容规范更多依赖配置发布流程,例如只描述角色定位、业务边界和行为基调,不应该在里面写权限绕过或具体工具调用规则。真正的能力范围由组装结果决定,即使提示词声称拥有某个 Tool,只要用户无权限,该 Tool 也不会出现在 Agent 中。

  1. 配置的提示词和最终子 Agent 提示词是什么关系?

参考回答:Paladin 配置的是业务域基础提示词。组装时系统还会追加统一的执行规则、语言要求以及当前用户实际可见的能力清单。能力清单根据权限过滤后的真实结果生成,而不是由配置人员手工维护,从而避免提示词中写着一个能力,但实际 Agent 没有该能力,或者反过来。

  1. Paladin 热更新具体是怎么实现的?

参考回答:服务启动时通过配置源注册监听器。配置发生变化后,监听器取得新的原始 JSON,重新执行完整解析和能力校验,生成新的领域快照。只有这些步骤全部成功后,系统才替换当前快照并清空主 Agent 缓存,后续请求会按照新配置重新组装 Agent。

  1. 配置更新失败时如何处理?

参考回答:由于新快照是在完整解析成功后才赋值,所以运行时配置解析失败时,旧快照不会被半成品覆盖,Agent 缓存也不会被清理。服务启动阶段则采用更严格的失败策略:配置源缺失、监听注册失败或者初始业务域配置非法都会导致启动失败,避免服务在未知能力集合下运行。

当前监听回调中没有单独做完善的异常捕获和告警,这是可以继续改进的地方。更完整的方案应该明确记录失败原因、配置摘要和刷新指标,同时保留最后一次成功快照。

  1. 热更新过程中如何保证线程安全?

参考回答:配置刷新和 Agent 创建通过读写锁协调。正常请求获取读锁并读取当前完整快照;热更新获取写锁,完成快照替换和缓存清理。在写锁释放前,新请求不会拿到“新配置配旧缓存”或者“旧配置配新缓存”的混合状态。

目前解析过程也在写锁内部完成,配置规模被限制后问题不大,但仍会增加刷新期间的请求等待。进一步优化可以先在锁外解析和校验,成功后只在写锁内完成快照替换和缓存失效。

  1. 为什么要缓存 Agent?缓存的依据是什么?

参考回答:Agent 组装涉及权限过滤、Tool 注册、Skill 解析和提示词构建,如果每次请求都重新完成会带来额外开销。当前实现会先计算用户在所有已配置能力中的有效权限集合,再对该集合生成指纹,权限集合相同的用户可以复用同一种主 Agent 装配结果。用户身份和会话状态由运行时存储隔离,不直接写入这个缓存键。

  1. 为什么缓存键里没有直接加入配置版本?

参考回答:当前代码的缓存键主要是权限指纹,业务配置变化时通过写锁下的全量缓存失效保证新请求重新创建 Agent。配置原文会计算摘要用于日志和识别快照,但没有直接拼入缓存键。这种方式实现简单,不过正确性依赖每条配置变更路径都必须执行缓存清理;另一种实现是把配置版本加入缓存键,可以减少漏清理风险,但需要处理旧缓存回收。

  1. 权限配置变化后,缓存怎么处理?

参考回答:用户权限配置也注册了独立监听器。权限配置刷新后,系统会清空主 Agent 缓存;下一次请求重新计算该用户的基础权限和有效权限集合,再构建或复用对应 Agent。权限指纹缓存按“权限集合”而不是用户 ID 保存,因此权限集合变化后自然会得到不同的指纹。

  1. 正在执行的请求会立即切换到新配置吗?

参考回答:不会强行修改已经创建并正在执行的 Agent。热更新主要对后续请求生效,正在执行的请求继续使用创建时的能力快照,这样可以避免执行到一半时 Tool 集合突然变化。相应地,紧急权限回收不能只依赖 Agent 缓存失效,关键写接口还应该在调用时重新校验权限或使用权限版本。

  1. 业务域被动态关闭后会发生什么?

参考回答:关闭的业务域在解析时会被跳过,新快照中不再包含该域,缓存清理后新请求也不会看到对应委托入口。已经运行中的请求通常不会被强制终止。对于长任务,还要结合任务自身的计划和状态决定是继续、暂停还是人工处理,不能仅根据新配置直接重复或跳过既有步骤。

  1. 业务域配置和用户权限配置为什么采用不同的失败策略?

参考回答:业务域配置非法时不能构造可靠的能力拓扑,因此初始加载选择失败启动,运行时刷新则保留旧快照。用户权限配置解析失败时,当前实现会得到空权限集合,整体上偏向 fail-closed,也就是宁可暂时不可用,也不继续授予无法确认的权限。后者安全性更高,但会影响所有用户可用性,因此需要配套告警和快速回滚。

  1. 如何测试这套配置化机制?

参考回答:需要覆盖四类测试。第一类是解析测试,包括空配置、非法格式、重复域、未知能力、系统能力引用和 Skill 资源缺失;第二类是权限测试,验证无权限能力不会出现在 Tool、Skill、子 Agent 描述和提示词中;第三类是热更新测试,验证成功刷新后缓存失效、失败刷新保留旧快照;第四类是并发测试,验证刷新与请求并发时不会产生混合快照。

当前仓库已有测试覆盖权限过滤、无可见能力时不创建子 Agent、仅 Skill 可见、运行时业务配置缺失以及权限解析等场景,但配置解析器和热更新并发场景还值得补充专项测试。

  1. 这个方案还有哪些不足和改进空间?

参考回答:第一,能力标识目前仍与 Spring Bean 名称有关,可以引入显式、稳定的能力 ID;第二,热更新失败需要增加独立日志、指标和告警;第三,可以把耗时解析移出写锁,只在快照切换时加锁;第四,对高风险 Tool 增加调用时权限复核,支持紧急撤权;第五,可以增加配置灰度、配置版本回滚和变更审计,避免错误配置直接影响全部实例。

4. 负责多格式文档理解链路,支持 XLSX、CSV、DOCX、PDF、PPTX、TXT、JSON 等 7 类文件的异步解析、内容分片、内嵌图片提取、BFS 上传和 Redis 临时存储,使 Agent 能按需读取文档而非一次性注入全文

4.1 具体实现方式

一、需求背景

用户希望直接把运营报表、策划文档、PDF、演示稿等文件交给 Agent 查询和总结。如果直接把原文件或全文放进 Prompt,会产生几个问题:

  • 不同格式的解析方式不同;
  • 大文档容易超过模型上下文;
  • 每次提问都注入全文,Token 成本和响应延迟较高;
  • Excel Sheet、PDF 页码、Word 标题等结构信息会丢失;
  • Office/PDF 中的图片无法通过纯文本解析;
  • 文件解析和 BFS 上传耗时较长,不适合阻塞聊天请求;
  • 不可信文件可能通过 ZIP Bomb、超大图片等方式耗尽资源。

因此,系统没有把文档处理成一个大字符串,而是转换成:

1
2
3
4
5
原文件
-> DocumentMeta
-> DocumentManifest
-> 多个压缩内容分片
-> 可按 imageId 访问的图片引用

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
用户上传文件
|
v
上传鉴权、格式和大小校验
|
v
写入本地临时文件 + Redis PARSING 元数据
|
v
HTTP 202 返回 fileId
|
v
专用线程池异步执行
|
+--> OOXML 安全预扫描
+--> 原文件上传 BFS
+--> Office/PDF 图片提取并上传 BFS
+--> 格式 Parser 生成结构化记录
+--> GZIP 分片写入 Redis
+--> Manifest 写入 Redis
|
v
发布 READY
|
v
Agent: inspect -> read -> image_read

核心组件包括:

组件 职责
DocumentController 上传和解析状态接口
DocumentService 上传校验、状态查询和按范围读取
DocumentParseService 异步解析、BFS、图片提取和状态编排
DocumentParser 七种文件格式的统一解析接口
DocumentPartAccumulator 累积结构化记录并按大小分片
DocumentRedisStore Redis 元数据、Manifest、分片和 TTL 管理
OfficeEmbeddedImageExtractor XLSX、DOCX、PPTX 图片提取
PdfEmbeddedImageExtractor PDF 内嵌图片提取
文档 Tool/Skill 控制 Agent 渐进式读取文档

三、上传和异步解析

上传接口位于 DocumentController.java,成功后返回 HTTP 202:

1
2
3
4
5
6
7
8
9
DocumentMeta meta = documentService.upload(username, file);

return ResponseEntity.accepted().body(
new UploadResponse(
meta.getFileId(),
meta.getFileName(),
meta.getFileType(),
meta.getStatus().name(),
meta.getExpiresAt()));

上传前,DocumentUploadAccessFilter 完成:

  • Manager 身份校验;
  • COMMON_UTILS_PERMISSION 权限校验;
  • 单实例上传并发限制;
  • Multipart 大小异常转换;
  • 并发满时返回 DOCUMENT_BUSY。

DocumentService.java 将文件复制到实例隔离的临时目录,校验大小和格式,创建 UUID fileId,并写入 PARSING 状态:

1
2
3
4
5
6
7
8
9
10
meta.setStatus(DocumentMeta.Status.PARSING);
meta.setTaskDeadlineAt(now + taskTimeoutMillis);
meta.setExpiresAt(now + ttlMillis);

if (!store.admit(username, meta)) {
throw documentBusy();
}

parseService.submit(username, meta, path);
return meta;

上传请求不等待 BFS 上传和解析完成。解析任务进入独立的 documentParseExecutor,默认配置为 1 个解析线程、10 个排队任务;队列已满时拒绝任务并清理临时文件和 Redis admission。

四、统一 Parser 设计

所有格式实现统一接口 DocumentParser.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface DocumentParser {

DocumentMeta.FileType fileType();

DocumentManifest parse(
Path path,
PartWriter writer,
Deadline deadline) throws Exception;

interface PartWriter {
void write(Integer sheetNo, int partNo,
byte[] compressedValue) throws Exception;
}
}

Parser 不返回无限长字符串,而是通过 PartWriter 边解析边写分片,最终只返回结构索引 Manifest。

DocumentParseService 启动时将所有 Parser 注册到 EnumMap<FileType, DocumentParser>。同一类型重复注册会启动失败,七种枚举类型没有全部注册也会启动失败。

五、七种格式的解析方式

格式 实现和输出结构
XLSX EasyExcel 流式读取所有 Sheet;每行输出 rowNo + cells,首行作为建议表头
CSV Commons CSV 流式读行;固定 sheetNo=0,每行输出 rowNo + cells
DOCX StAX 读取 OOXML;提取段落、表格行、标题层级和标题路径
PDF PDFBox 按页提取文本;每页输出 itemNo + pageNo + text
PPTX StAX 按幻灯片解析;提取标题、文本、表格行和备注
TXT UTF-8 流式读取;按行处理,超长行继续拆成有界文本块
JSON Jackson 流式解析;展平为 JSON Pointer + type + value 标量记录

例如 PDF 解析位于 PdfDocumentParser.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (int pageNo = 0; pageNo < pages; pageNo++) {
deadline.check();

stripper.setStartPage(pageNo + 1);
stripper.setEndPage(pageNo + 1);
stripper.writeText(document, output);

Map<String, Object> record = new LinkedHashMap<>();
record.put("itemNo", (long) pageNo);
record.put("pageNo", pageNo);
record.put("text", output.value());

accumulator.add(pageNo, record);
}

JSON 不把整个对象反序列化到内存,而是递归消费 Jackson Token。例如:

1
{"project":{"name":"OGV","budget":1000}}

会转换为类似记录:

1
2
{"itemNo":0,"path":"/project/name","type":"string","value":"OGV"}
{"itemNo":1,"path":"/project/budget","type":"number","value":"1000"}

六、内容分片

通用分片逻辑位于 DocumentPartAccumulator.java。

每条解析记录先序列化为 JSON,用估算字节数累积;接近目标大小时执行 flush:

1
2
3
4
5
6
7
8
9
10
11
12
if (!buffer.isEmpty()
&& estimatedBytes + encoded.length + 1 > partTargetBytes) {
flush();
}

buffer.add(record);

byte[] value =
DocumentPartCodec.gzipJson(objectMapper, buffer, partMaxBytes);

writer.write(sheetNo, partNo, value);
parts.add(new Part(partNo, partStart, end, sheetNo));

默认参数为:

1
2
3
4
5
目标分片大小:256 KiB
最大分片大小:512 KiB
单文件最大分片数:1024
解析后最大非压缩数据:128 MiB
Redis 单文件最大写入量:64 MiB

分片内容使用 JSON 数组表达,再通过 GZIP 压缩。Manifest 不保存正文,只记录每个分片覆盖的逻辑范围:

1
partNo + itemStart + itemEnd + sheetNo

Agent 调用时传的是 offset + limit,不是直接指定内部 partNo。服务端根据范围找到相交分片,批量读取、解压,再精确过滤对应记录。

七、Manifest 结构

DocumentManifest.java 保存文档的结构索引:

1
2
3
4
schemaVersion / parserVersion / fileType
recordCount / paragraphCount / tableCount
sheets / parts / headings / images
compressedBytes

其中:

  • Sheet 保存 Sheet 编号、名称、建议表头、行数和分片范围;
  • Part 保存分片编号和逻辑记录范围;
  • Heading 保存 DOCX 标题、层级、路径和所在 item;
  • EmbeddedImage 保存 imageId、URL、类型和文档内锚点。

需要注意:fileId、文件名、状态、过期时间和原文件 BFS URL 保存在 DocumentMeta,不在 Manifest 中。

八、BFS 上传

异步任务会先将原文件上传 BFS:

1
2
3
4
5
6
7
downloadUrl = bfsUpload.uploadDocument(
path,
meta.getFileId() + meta.getFileType().getSuffix(),
meta.getFileType().getContentType(),
deadlineAt);

meta.setDownloadUrl(downloadUrl);

BfsUploadIntegration.java 对文档使用基于本地文件的流式 RequestBody,不会先把 100 MiB 文件整体读入 Java 堆。

上传最多重试三次,并受任务截止时间以及连接、写入、读取、总调用超时共同限制。只有 BFS 返回合法 HTTP(S) URL 后,解析流程才继续。

原文件地址保存为 DocumentMeta.downloadUrl;内嵌图片也上传 BFS,但图片 URL 只在 Agent 明确调用 document_image_read 时作为多模态内容返回。

九、内嵌图片提取

支持内嵌图片的类型为:

1
XLSX、DOCX、PPTX、PDF

Office 文件本质上是 ZIP 包。OfficeEmbeddedImageExtractor.java 解析 OOXML relationship,找到实际媒体文件及其位置:

  • XLSX:记录 sheetNo、row、column;
  • DOCX:记录对应的段落或表格行 itemNo;
  • PPTX:记录对应的幻灯片 itemNo;
  • 同一个媒体文件多次引用时只上传 BFS 一次。

PDF 使用 PDFBox 遍历页面资源和嵌套 Form XObject,找到 PDImageXObject。图片会渲染为 PNG,校验像素数和文件大小后上传 BFS,并用页码作为 itemNo。

每个引用生成:

1
img-0001、img-0002……

Manifest 中只返回图片 ID和位置摘要,不默认把所有图片送入模型。

十、Redis 临时存储

DocumentRedisStore.java 将数据拆成多类 Key:

1
2
3
4
{owner}:active
{owner}:file:{fileId}:meta
{owner}:file:{fileId}:manifest
{owner}:file:{fileId}:{type}:{sheetNo}:part:{partNo}

owner 是用户名的 SHA-256,既避免直接暴露用户名,也让同一用户的 Key 通过 Redis hash tag 落到同一槽位。

Redis Lua 脚本负责:

  • 原子检查单用户活动文件上限并创建元数据;
  • 只允许在 PARSING 且未超时状态下写分片;
  • 只允许存在 Manifest 且已有 BFS URL 时发布 READY;
  • 解析失败时更新 FAILED 并释放活动配额;
  • 为 Meta、Manifest 和 Part 设置一致的绝对过期时间。

默认正常数据 TTL 为 24 小时,失败状态清理成功后只保留 10 分钟,因此这是临时文档存储,不是永久知识库。

十一、READY 发布屏障

核心解析顺序位于 DocumentParseService.java:

1
2
3
4
5
6
上传原文件 BFS
-> 提取并上传图片
-> Parser 写全部内容分片
-> 写 Manifest
-> status = READY
-> publishReady

READY 是文档可读性的发布屏障。Agent 只有在以下条件满足后才能看到 READY:

  • 原文件 BFS URL 已存在;
  • 所有解析步骤已经返回成功;
  • 内容分片已写入;
  • Manifest 已写入;
  • 任务和解析截止时间没有到期。

这不是一个覆盖所有 Redis Key 的大事务,而是通过条件写、固定发布顺序和失败清理构成的应用级发布协议。若读取时发现 Manifest 或分片缺失,服务端仍会按 DOCUMENT_EXPIRED 拒绝返回半成品。

十二、Agent 按需读取流程

聊天请求携带 fileId 时,ChatController.java 会检查:

  • 用户拥有文档权限;
  • fileId 属于当前用户;
  • 文档状态为 READY;
  • Manifest 和 BFS URL 仍然存在。

随后只把轻量引用加入用户消息:

1
2
3
4
5
<temporary_document_refs>
[
{"fileId":"UUID","name":"运营报表.xlsx","type":"XLSX"}
]
</temporary_document_refs>

相关代码位于 ChatService.java。这里不会注入正文、分片或下载 URL。

公共域 Agent 加载 document-reading-workflow 后,按以下顺序操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
document_inspect(fileId)
|
v
获取 Sheet、记录数、标题和图片引用
|
v
document_read(fileId, sheetNo, offset, limit)
|
v
只读取当前问题需要的记录
|
v
确实需要视觉信息时
document_image_read(fileId, imageIds)

document_read 对 XLSX、CSV 强制要求 sheetNo,其他类型按 itemNo 顺序读取。document_image_read 将指定 BFS 图片构造成 AgentScope ImageBlock,交给多模态模型理解,默认单次最多读取 10 张。

十三、为什么能避免全文注入

该设计通过四层机制控制上下文:

  1. 聊天消息只携带 fileId、文件名和类型。
  2. document_inspect 只返回结构,不返回全文。
  3. document_read 使用 offset/limit 定向读取逻辑范围。
  4. 图片只按 imageId 单独加载,不自动注入全部图片。

Skill 还明确要求:总结和分析必须由公共域 Agent 基于必要分页结果直接完成,只向主 Agent 返回结论和少量引用,禁止把全文转交主 Agent、写入临时状态或长期记忆。

文档引用会随会话历史保存,使用户后续说“继续看刚才的文档”时可以找回准确 fileId,但历史中仍然只保存轻量引用,不保存文档正文。

十四、安全与资源限制

系统对不可信文件设置了多层限制:

限制 作用
文件最大 100 MiB 控制上传和本地磁盘占用
每消息最多 10 个文件 控制一次请求规模
单用户活动文件上限 避免单用户占满解析队列
ZIP 条目、展开大小、压缩率 防止 ZIP Bomb
禁止宏、加密包和嵌套 ZIP 降低复杂格式风险
XML 单项、shared strings、styles 上限 限制 OOXML 高风险结构
禁用 XML DTD 和外部实体 防止 XXE
页数、幻灯片数、Sheet 数和记录数上限 控制 CPU 和内存
图片数量、单图大小、总大小和像素数 防止图片资源耗尽
JSON 深度和标量大小 防止深层递归和超大字符串
分片、Manifest、Redis 总字节数 控制临时存储
解析截止时间 防止后台任务无限执行

文件类型虽然先由扩展名确定,但还会校验真实结构:PDF 检查 %PDF-,OOXML 检查 ZIP 和必要入口,文本类要求合法 UTF-8 且不能包含空字节。

fileId 必须是 UUID v4,并在 Redis Key 中绑定当前用户名。其他用户即使猜到 fileId,也无法通过自己的 owner Key 读取对应内容。

十五、失败和生命周期处理

解析过程中会记录已经写入的 Redis Key。如果任意阶段失败:

  1. 删除已经写入的 Part 和 Manifest;
  2. 将状态更新为 FAILED;
  3. 保存明确的 failureCode;
  4. 清理成功时把失败状态 TTL 缩短为 10 分钟;
  5. 删除本地临时文件;
  6. 释放活动任务配额。

本地临时目录还设置定时清理。activePaths 中正在解析的文件不会被误删;超过保留时间且不再活动的遗留文件会被清理。

状态接口还会检测排队任务和解析任务是否超过截止时间,并通过 Redis 条件更新避免过期状态覆盖并发产生的新 READY 状态。

十六、实现边界

这套能力有几个需要准确说明的边界:

  • 只支持 .xlsx、.csv、.docx、.pdf、.pptx、.txt、.json,不支持旧版 .xls/.doc/.ppt;
  • PDF 使用 PDFBox 提取文本,没有 OCR;
  • 扫描 PDF 正文为空时,只能按需读取页面内嵌图片,不能声称已识别扫描文字;
  • DOCX/PPTX 主要提取文本、表格、标题和图片引用,不还原完整排版;
  • 文档数据受 TTL 管理,不是永久知识库;
  • Redis 中保存的是解析后的结构和压缩分片,原文件与可访问图片保存在 BFS;
  • 文档内容、文件名、图片和替代文本均被视为不可信用户数据,不能作为系统指令执行。

4.2 提问

4.2.1 自己想到的问题

4.2.2 AI想到的问题

  1. 为什么不直接把文件或者全文交给大模型?

参考回答:不同模型对 Office、PDF 等文件的支持不一致,而且大文件很容易超过上下文窗口。即使模型能够接收文件,一次性注入全文也会增加延迟和 Token 成本,并且不方便定位某个工作表、页面或段落。因此系统先把文档解析成结构化索引和小分片,让 Agent 根据问题按需读取。

  1. 整个文档理解链路是怎样的?

参考回答:用户上传文件后,系统先完成身份、权限、文件类型和大小校验,再把文件保存到实例临时目录,并创建解析中的文档记录。后台线程依次完成安全扫描、原文件上传 BFS、内嵌图片提取、正文解析、Redis 分片写入和结构清单写入。全部成功后才把文档发布为可读状态,随后 Agent 可以通过结构检查、正文读取和图片读取三类能力使用文档。

  1. 为什么采用异步解析?

参考回答:Office 解压、PDF 文本提取、图片转换和 BFS 上传都可能耗时较长,不适合占用上传请求线程。上传接口只返回文件编号、类型、解析状态和过期时间,前端通过状态接口查询进度。聊天请求只有在文件解析完成后才允许携带该文件,避免 Agent 读取半成品。

  1. 异步解析如何做流量控制?

参考回答:解析使用独立的有界线程池和有界队列,队列满时直接拒绝新任务并返回繁忙,而不是无限堆积。上传入口还有单实例并发信号量,Redis 侧也限制单用户同时处于活动状态的文件数量。服务启动时还会校验任务总超时时间能够覆盖预期排队时间,避免任务还没开始就已经过期。

  1. 文档模块的核心组件如何分工?

参考回答:上传服务负责入口校验、临时文件和状态创建;解析服务负责编排后台流程;不同格式的解析器负责把内容转换为统一记录;Redis 存储负责元数据、结构清单和正文分片;BFS 负责保存原文件和图片;Agent Tool 负责向模型提供有限、按需的读取接口。格式差异被限制在解析器内部,其他层只处理统一数据结构。

  1. 7 种文件格式是如何统一接入的?

参考回答:系统定义了统一解析接口,每个解析器声明自己支持的文件类型,并通过回调逐个输出压缩后的正文分片,最后返回文档结构清单。解析服务启动时会校验所有枚举中的文件类型都有对应解析器,并拒绝重复注册。新增格式时主要增加文件类型、解析器、安全校验和必要的图片处理,存储及 Agent 读取流程可以复用。

  1. 不同格式解析后分别保留什么结构?

参考回答:

  • XLSX 按工作表和行保存,保留工作表名称、行数及第一行建议表头。
  • CSV 视为编号为 0 的单工作表,按行保存。
  • DOCX 保存段落、表格行和标题层级路径。
  • PDF 以页面为最小逻辑项,保存页码和页面文本。
  • PPTX 以幻灯片为最小逻辑项,保存标题、正文、表格和备注。
  • TXT 按有界文本块保存,同时保留行号和行内分块编号。
  • JSON 使用流式解析,将叶子值转换为带 JSON Pointer 路径、类型和值的记录。
  1. 为什么 XLSX 使用 EasyExcel?

参考回答:Excel 文件可能包含大量行,如果使用一次性加载整个工作簿的方式,内存占用会随文件大小增长。EasyExcel 可以通过监听器逐行消费数据,解析器只保留当前待写分片的少量记录。每个工作表独立维护分片编号,既降低内存压力,也方便后续按工作表读取。

  1. DOCX 和 PPTX 为什么采用流式 XML 解析?

参考回答:DOCX 和 PPTX 本质上是 OOXML 压缩包,直接构造完整对象模型可能占用较多内存。当前实现读取压缩包中的核心 XML,通过流式事件提取段落、表格、标题、幻灯片正文和备注。同时关闭 DTD 和外部实体能力,减少 XML 外部实体类攻击风险。

  1. PDF 是如何解析的?支持 OCR 吗?

参考回答:PDF 使用 PDFBox,按页面提取文本,每页作为一个逻辑记录,并使用临时文件型缓存减少大文件对堆内存的压力。当前代码不提供 OCR,因此扫描版 PDF 可能提取不到正文。扫描页存在图片时,系统可以按需读取图片让多模态模型理解,但不能宣称已经通过 OCR 得到了页面文字。

  1. JSON 为什么不直接反序列化成一个大对象?

参考回答:大 JSON 整体反序列化可能占用大量内存,也难以对嵌套深度和超长字符串进行细粒度限制。当前实现使用流式 JSON 解析器,逐层遍历对象和数组,把标量值输出为带路径的记录。同时限制嵌套深度、路径长度、字符串和数字长度,避免恶意 JSON 消耗过多资源。

  1. TXT 和 CSV 的编码如何处理?

参考回答:当前实现统一要求 UTF-8,并允许开头存在 BOM。解析器使用严格字符解码,遇到非法字节会失败,而不是静默替换乱码。TXT 还会拒绝空字符等明显二进制内容;CSV 则限制列数、单元格字节数和单行总字节数。

  1. 什么是文档 Manifest,为什么需要它?

参考回答:Manifest 可以理解为文档的结构目录,它描述文件类型、解析器版本、记录数量、工作表、标题、分片范围和图片引用。文件名、下载地址和状态等基础信息单独保存在文档元数据中。Agent 先读取结构目录,就能判断应该读取哪个工作表、哪一段正文以及是否存在相关图片,而不需要盲目扫描全文。

  1. 正文是如何分片的?

参考回答:解析器不断向分片累积器写入统一记录,当估算大小达到目标值时,就把当前记录集合编码为 JSON 并使用 GZIP 压缩,然后写入 Redis。每个分片会记录覆盖的起止行号或顺序项编号。系统同时限制单条记录、单分片、总记录数、总解压数据量和分片数量,防止单个文件占用过多资源。

  1. 为什么按字节大小分片,而不是固定每 100 行一个分片?

参考回答:不同记录大小差异很大,一百行短文本和一百行长文本的存储及模型成本完全不同。按目标字节数分片可以让 Redis value 和单次读取结果更加稳定。行号或顺序项仍然保存在分片索引中,因此读取接口可以继续按业务范围查询。

  1. Agent 按 offset 和 limit 读取时,如何找到对应分片?

参考回答:Manifest 中记录了每个分片覆盖的逻辑范围。读取时先筛选与目标区间相交的分片,再通过 Redis 批量读取并解压,最后按照记录自身的行号或顺序项编号做一次精确过滤。这样不会扫描所有分片,也不会因为分片边界与请求范围不完全一致而返回额外内容。

  1. 如何保证 Agent 不会读到半解析数据?

参考回答:正文分片会先逐个写入 Redis,全部解析成功后再写 Manifest,最后才发布可读状态。发布时会检查文档仍处于解析中、没有超时、Manifest 已存在并且原文件地址有效。读取端只接受可读状态,同时要求元数据、Manifest 和必要分片都存在。

需要准确说明的是,这不是一个覆盖所有分片的多 Key 数据库事务,而是通过写入顺序和最终发布屏障保证正常流程一致性。如果某个分片后来被异常删除或淘汰,读取端会把文档视为不可用,而不会返回不完整结果。

  1. 为什么 Redis 中不把全文放在一个 Key 里?

参考回答:单个大 value 会增加网络传输、序列化和内存峰值,即使只需要一页内容也必须读取全部数据。拆成元数据、Manifest 和正文分片后,可以按范围批量读取,也方便解析失败时删除已经写入的部分。每个 Key 使用同一个绝对过期时间,避免后写入的分片比文档本身多存活很久。

  1. BFS 在这条链路里承担什么职责?

参考回答:BFS 用来保存原文件和提取后的内嵌图片,Redis 只保存临时结构化内容和 URL 引用。原文件上传使用流式请求体,不需要把整个大文件再次读入 JVM 堆。这样二进制文件和高频临时索引分别由适合的存储系统承载。

  1. 为什么要在发布可读状态前完成 BFS 上传?

参考回答:原文件引用和图片引用也是文档完整性的一部分。如果先标记可读,再异步上传 BFS,Agent 可能拿到一个缺少原文件或图片地址的半成品。当前流程只有 BFS 上传、图片提取、正文分片和 Manifest 全部成功后才发布可读状态。

  1. BFS 上传失败如何处理?

参考回答:上传集成会在总截止时间内进行有限次数重试,并为连接、读写和总调用设置超时。如果仍然失败,整个解析任务进入失败状态,不会发布为可读。已经写入的 Redis 分片会被清理,但当前代码没有看到对已上传 BFS 文件的反向删除,因此后续可以增加对象生命周期策略或补偿删除,避免形成孤儿文件。

  1. Office 内嵌图片是如何提取的?

参考回答:系统读取 OOXML 中的文档关系、绘图关系和媒体文件,确定图片属于哪个工作表、行列、段落或幻灯片。支持 PNG、JPEG、GIF 和 WebP,并校验实际文件签名,不能只相信扩展名。同一个媒体资源在文档中出现多次时只上传一次,但可以生成多个位置引用。

  1. PDF 图片是如何提取的?

参考回答:PDFBox 会递归遍历页面资源和表单对象,找到嵌入图片,并记录对应页码。图片转换为 PNG 后检查像素数、单图大小和总大小,再上传 BFS。同一个底层图片对象会复用上传结果,解析失败或超限的个别图片会被跳过,避免图片问题完全阻塞正文读取。

  1. 为什么不给模型一次性加载所有图片?

参考回答:图片的多模态推理成本较高,而且很多用户问题只需要正文。Manifest 和正文读取结果只返回图片编号、位置和替代文本,不代表模型已经理解图片内容。只有用户问题确实涉及图表、截图或图片中的文字时,Agent 才选择相关图片编号调用图片读取接口,并且单次读取数量有上限。

  1. 系统如何识别文件类型?只看后缀吗?

参考回答:入口先根据允许的后缀确定目标解析器,但不会只相信后缀。PDF 会检查文件头,文本类文件会检查 UTF-8 和二进制空字符,OOXML 会检查 ZIP 结构、必要入口文件和内容类型文件。这个方案能过滤常见伪造文件,但还不是完整 MIME 嗅探,后续可以增加更严格的文件魔数检测。

  1. 如何防止 ZIP Bomb?

参考回答:XLSX、DOCX 和 PPTX 本质上都是 ZIP,单纯限制上传文件大小并不够。系统先根据 ZIP 目录检查条目数量、声明解压大小、压缩率、图片数量和图片大小;后台解析前还会真正读取条目,统计实际展开字节,并对核心 XML、共享字符串和样式文件设置单独上限。两次校验可以降低伪造 ZIP 元数据绕过检查的风险。

  1. 除了 ZIP Bomb,还有哪些资源限制?

参考回答:系统限制上传文件大小、单用户活动文件数、解析线程和队列、解析时间、总记录数、正文总字节数、分片数量、Manifest 大小、PDF 页数、PPT 页数、JSON 深度、图片像素和 Redis 单文件写入量。这些限制覆盖磁盘、内存、CPU、Redis 和模型上下文多个资源维度。

  1. 用户之间如何实现文档隔离?

参考回答:上传、状态查询和聊天使用统一的登录身份,并检查文档能力权限。Redis Key 不只是使用文件编号,还会加入用户名的 SHA-256 摘要;即使另一个用户猜到文件 UUID,也会查询到完全不同的 Key 空间。Agent Tool 读取时使用运行上下文中的当前用户身份,不接受模型自行传入用户名。

  1. 如何防止模型伪造 fileId?

参考回答:聊天服务只把已经解析完成的文件转换成轻量引用并注入对话,引用中包含服务端生成的 UUID。文档工作流要求 Agent 原样使用这个 UUID,不能根据文件名、路径或下载地址自行拼接。服务端还会校验 UUID 格式以及当前用户下是否存在对应文档。

  1. 文档内容中的提示词注入如何处理?

参考回答:文件名、正文、图片替代文本和图片内容都被视为不可信用户数据,不能当成系统指令执行。Agent 的文档工作流明确要求这些内容只用于回答当前问题,并且不能把正文写入长期记忆。真正的访问控制仍由服务端 Tool、用户绑定和范围校验保证,而不是依赖文档中的文字。

  1. Redis 数据和本地临时文件如何过期?

参考回答:元数据、Manifest 和分片使用统一的绝对过期时间,默认属于短期临时数据。解析失败时通常只保留较短时间的失败状态,便于前端查看错误。本地临时文件无论解析成功还是失败都会尝试删除,同时定时任务会清理超过保留时间、且不在活动集合中的遗留文件。

  1. 解析超时是如何处理的?

参考回答:文档有整体任务截止时间,后台 Worker 启动后还有更短的单次解析截止时间。解析器在读取文件、处理记录、上传图片和写分片过程中都会检查截止时间。状态查询也会识别长期停留在解析中的任务,通过条件更新将其标记为失败,避免任务无限处于处理中。

  1. 服务重启后,正在解析的文档能自动恢复吗?

参考回答:当前异步任务使用的是进程内线程池,不是持久化消息队列,因此服务重启后不会自动恢复原解析任务。Redis 中的文档会暂时保持解析中,之后在状态查询时根据截止时间转为失败;本地遗留文件由定时任务清理。若要求真正的重启续跑,应使用持久化任务队列,并让解析步骤具备幂等和断点恢复能力。

  1. 为什么要求 Agent 先 inspect 再 read?

参考回答:不同文件的结构差异很大,表格可能有多个 Sheet,Word 可能有标题层级,PDF 和 PPT 则以页为单位。先查看结构,Agent 才能知道有效范围和图片位置,然后选择与问题相关的部分读取。这既减少上下文,也降低读取错误 Sheet 或越界范围的概率。

  1. 为什么 XLSX 和 CSV 必须指定 sheetNo?

参考回答:XLSX 可能包含多个工作表,如果省略工作表编号,服务端无法确定用户希望读取哪一张表。CSV 虽然只有一张逻辑表,也统一使用编号 0,从而让表格类文件共享相同的读取协议。其他文档则按照顺序项读取,不需要工作表编号。

  1. 当前按需读取还有什么不足?

参考回答:读取接口会校验 offset 非负和 limit 大于零,但当前代码没有明显的单次 limit 上限。如果模型传入非常大的范围,仍可能一次读取大量分片并返回较大的工具结果。后续应增加单次最大记录数和最大返回字节数,并让 Agent 自动分页读取。

  1. 为什么文档正文不能写入 Agent 长期记忆?

参考回答:文档属于用户上传的临时、不可信数据,而且 Redis 内容会过期。如果把全文写入长期记忆,不仅占用大量存储,还可能造成敏感信息长期残留,并让过期文档内容继续影响后续对话。因此长期记忆只保留轻量引用或必要结论,需要原文时重新通过受控工具读取。

  1. 如何测试这套文档链路?

参考回答:需要为 7 种格式分别准备正常、空文件、超限和损坏样例,验证结构清单、分片边界和范围读取;为 OOXML 增加 ZIP Bomb、路径穿越、宏、嵌套压缩和超大图片用例;为 Redis 测试发布与超时竞争、缺失分片和 TTL;为 BFS 测试超时、重试和部分成功;最后做跨用户读取和 Agent 按需读取的端到端测试。

从当前仓库能看到完整的生产实现和大量防护逻辑,但没有检索到该文档模块对应的专项测试类,面试时不应夸大已有测试覆盖。更稳妥的表述是说明自己设计或执行过哪些验证,并把缺失的自动化测试作为改进项。

  1. 这个方案还有哪些可以继续优化的地方?

参考回答:第一,引入持久化任务队列,支持服务重启后的解析恢复;第二,为 BFS 文件和图片增加失败补偿及生命周期清理;第三,限制单次正文读取结果大小;第四,补充 OCR 和版面分析,提升扫描 PDF 与复杂表格能力;第五,增加解析耗时、队列等待、失败类型、Redis 写入量和图片提取量等监控;第六,完善 7 种格式和恶意文件的自动化测试。