技术拆解-Readify叙事可视化

从文字到画卷:揭秘大模型驱动下的小说可视化技术

前言:当阅读不止于阅读

长篇小说,以其宏大的叙事、复杂的人物关系和交错的情节线,为我们构建了一个个引人入胜的世界。但传统的线性阅读方式,有时会让我们难以一窥故事全貌,无法清晰地梳理人物的命运交织和关键事件的脉络。

如果,我们能将一本厚重的小说,变成一幅可交互、可探索的动态画卷呢?

本文将深入剖析一个基于大语言模型(LLM)的小说可视化系统。它不仅仅是将文本内容进行简单的图表化,而是通过深度语义理解,将小说解构、重组,最终以一个全新的、多维的”故事线”视角呈现给读者。我们将一起探索,系统如何从上传一个.txt文件开始,经历一系列复杂的处理,最终生成一个可供用户自由探索和对话的交互式可视化界面。

一、系统架构:构建一座连接文本与视觉的桥梁

在深入细节之前,我们先来看一下系统的整体架构。这是一个典型的分层架构,各层职责分明,通过API进行通信,保证了系统的高内聚和低耦合。

graph TB
    subgraph "用户体验层 (Frontend)"
        A[书籍上传]
        B[视角选择]
        C[可视化界面]
        D[对话交互]
    end

    subgraph "应用服务层 (Backend)"
        E["API网关 - Controllers"]
        F["核心服务 - Services"]
        G["数据实体 - Entities"]
    end

    subgraph "智能与外部服务 (AI & External)"
        H["大语言模型 (LLM)"]
        I["文生图服务"]
        J["持久化/缓存"]
    end

    A --> E
    B --> E
    C --> E
    D --> E

    E --> F
    F --> G
    F --> H
    F --> I
    F --> J
  • 用户体验层:负责用户交互,包括文件上传、进度展示、结果可视化和对话。
  • 应用服务层:系统的核心,负责处理业务逻辑。
    • Controllers:作为API网关,接收前端请求并分发给相应的服务。
    • Services:业务逻辑的核心实现,如BookService负责书籍管理,ReadifyStorylineService是故事线分析的主引擎,ChatService则驱动对话功能。
    • Entities:定义了系统中最核心的数据结构,如Book, Event, Participant等,是连接非结构化文本和结构化数据的关键。
  • 智能与外部服务
    • **大语言模型 (LLM)**:系统的大脑,负责文本理解、事件提取、主题生成等核心智能任务。
    • 文生图服务:为故事中的关键角色和视角生成具象化的图片,增强视觉体验。
    • 持久化/缓存:存储任务状态、分析结果和中间数据。在当前设计中,主要通过ConcurrentHashMap实现内存级缓存。

二、完整处理流程:一本小说如何”变身”?

整个过程始于用户上传,终于交互式探索。我们可以将其分为五个关键阶段。

阶段一:书籍上传与预处理

  1. 上传与异步接收:用户上传.txt文件后,BookController调用BookService,生成bookId并启动异步解析。
  2. 后台解析NovelParserService将纯文本切分为结构化的章节。
  3. 生成推荐视角:这是初始处理阶段的亮点。在书籍的基本结构解析完成后,系统会立即调用LLM,为用户推荐多个有趣的分析视角。

核心问题:推荐视角是如何生成的?

这个过程由BookServiceImpl中的processBookAsync方法驱动,是一个典型的AI-Powered功能:

  1. 结构化输入:系统将解析出的章节分析数据(book.getChapterAnalyses())作为LLM的输入,而非小说全文。这是一种高效的”降维打击”,让LLM能基于关键信息进行归纳。
  2. LLM大脑:调用LLM,使用一个专门的Prompt(READIFY_BOOK_PERSPECTIVE)来生成视角标题和描述。
  3. 多模态丰富:调用ImageGenerateService,为每个文本视角配上AI生成的具象化图片,提升用户体验。

代码示例:LLM生成推荐视角 (BookServiceImpl.java)

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
private void processBookAsync(String bookId, String content, String title, String author) {
CompletableFuture.runAsync(() -> {
try {
// 1. 使用NovelParserService解析书籍,获得章节分析等结构化数据
Book book = novelParserService.parseNovel(content, title, author);
book.setBookId(bookId);

// 2. 调用LLM生成推荐视角
String perspectiveJson = llmApiUtil.callLLMApi(
promptService.getPrompt(PromptFileName.READIFY_BOOK_PERSPECTIVE),
JsonUtils.toJsonString(book.getChapterAnalyses()) // 使用章节分析作为输入
);

// 3. 解析LLM返回的JSON
BookPerspectiveResponse bookPerspective = JsonUtils.parse(
FormatUtils.removeMdJsonSymbol(perspectiveJson),
new TypeReference<>() {}
);
// 为每个视角分配唯一ID
bookPerspective.getPerspectiveList().forEach(perspectiveItem -> {
perspectiveItem.setId("perspective_" + UuidUtils.getShortUuid());
});

bookPerspective.setBookTitle(book.getTitle());
// 4. 为书籍和每个视角生成图片
imageGenerateService.generateBookPerspectiveImageUrl(bookPerspective);

book.setBookPerspectiveResponse(bookPerspective);

// 5. 存入缓存,更新状态为COMPLETED
bookCache.put(bookId, book);
bookStatusCache.put(bookId, BookStatus.COMPLETED);

} catch (Exception e) {
// ... 错误处理
}
});
}

阶段二:提交分析与异步任务

当用户从推荐列表中选择一个视角后,真正耗时的深度分析开始了。

  1. 提交任务:用户选择视角,点击”开始分析”。ReadifyStorylineController接收请求。
  2. 创建异步任务:这是系统设计的核心。为了避免长时间的分析阻塞用户界面,后端采用异步任务模型ReadifyStorylineService会生成taskId,将任务状态置为QUEUED,然后将核心分析逻辑封装到CompletableFuture中,交由线程池处理,并立即向前端返回taskId

代码示例:异步任务提交与编排 (ReadifyStorylineService.java)

下面的代码展示了如何使用CompletableFuture将”小说分析”和”文生图”两个阶段编排成一个非阻塞的流水线。

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
public TaskSubmissionResult submitNovelUploadTask(String bookId, String perspective, String description, String id) {
String taskId = generateTaskId(bookId, perspective, description, id);
String traceId = MDC.get(HeaderName.X_TRACE_ID.getName());

// 设置任务初始状态为排队
taskStatusMap.put(taskId, TaskStatus.QUEUED);

// 1. 异步执行核心分析任务
CompletableFuture<ProcessResult> future = CompletableFuture.supplyAsync(() -> {
try {
MDC.put(HeaderName.X_TRACE_ID.getName(), traceId);
taskStatusMap.put(taskId, TaskStatus.RUNNING);
// 执行耗时的小说分析
ProcessResult result = processNovelUpload(bookId, perspective, taskId);

taskResultMap.put(taskId, result);
taskStatusMap.put(taskId, TaskStatus.SUCCEEDED);
return result;
} catch (Exception e) {
// ... 错误处理
throw new RuntimeException(e);
} finally {
MDC.clear();
}
}, requestExecutor)
// 2. 在分析完成后,继续异步执行文生图任务
.thenCompose(result ->
CompletableFuture.supplyAsync(() -> {
try {
MDC.put(HeaderName.X_TRACE_ID.getName(), traceId);
// 为故事线中的人物生成图片
generateImagesForStoryline(result);
taskStatusMap.put(taskId, TaskStatus.GRAPH_SUCCEED);
return result;
} catch (Exception e) {
// ... 文生图失败不影响主流程
return result;
} finally {
MDC.clear();
runningTasksMap.remove(taskId);
}
}, requestExecutor)
);

// 存储任务句柄,用于后续可能的取消操作
runningTasksMap.put(taskId, future);

return new TaskSubmissionResult(taskId, TaskStatus.QUEUED);
}

阶段三:核心分析引擎的运转(魔法发生的地方)

后台线程池中,一场围绕文本的深度解构正在上演。这并非一次简单的LLM调用,而是一套精心设计的分析流水线

1. 智能文本分段 (Smart Segmentation)

为了处理可能非常巨大的小说文本并规避LLM的上下文长度限制,系统首先会将每个章节的内容进行智能分段。它并非暴力切割,而是会寻找句子末尾的标点符号(如)作为最佳分割点,以保证语义的完整性。

代码示例:智能文本分段 (ReadifyStorylineService.java)

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
private List<ParagraphWithPosition> segmentText(String text) {
final int MAX_SEGMENT_LENGTH = 40000;
// ... 如果文本长度小于最大值,直接返回

// 计算分段数和每段的平均长度
int segmentCount = (int) Math.ceil((double) text.length() / MAX_SEGMENT_LENGTH);
int segmentLength = text.length() / segmentCount;

for (int i = 0; i < segmentCount; i++) {
int startPos = i * segmentLength;
int endPos = (i == segmentCount - 1) ? text.length() : startPos + segmentLength;

// 寻找最佳分割点,避免在句子中间断开
int adjustedEndPos = findBestSplitPoint(text, endPos, startPos + segmentLength + 1000);
if (adjustedEndPos > startPos) {
endPos = adjustedEndPos;
}
// ... 添加分段到列表
}
return paragraphs;
}

private int findBestSplitPoint(String text, int preferredPos, int maxSearchPos) {
int searchEnd = Math.min(maxSearchPos, text.length());
// 从首选位置向后搜索句子结束符
for (int i = preferredPos; i < searchEnd; i++) {
char c = text.charAt(i);
if (c == '。' || c == '!' || c == '?') {
return i + 1; // 返回标点符号后的位置
}
}
return preferredPos; // 未找到则返回原位置
}

2. 并发事件提取 (Concurrent Event Extraction)

系统利用ForkJoinPool对所有文本段落进行并行处理,每个段落都会被发送给LLM进行事件提取。这极大地缩短了处理时间。

代码示例:并发事件提取 (ReadifyStorylineService.java)

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
private List<Event> extractEvents(Book novel, String perspective) {
String traceId = MDC.get(HeaderName.X_TRACE_ID.getName());

// 使用自定义线程池进行并发处理
List<CompletableFuture<List<Event>>> futures = novel.getContentUnits().stream()
.map(contentUnit -> CompletableFuture.supplyAsync(() -> {
MDC.put(HeaderName.X_TRACE_ID.getName(), traceId);

// 1. 文本分段
List<ParagraphWithPosition> paragraphs = segmentText(contentUnit.getContent());

// 2. 调用LLM分析事件
List<Event> events = analyzeEventsWithLLM(paragraphs, contentUnit, perspective);

return events;

}, taskExecutor)) // 使用名为 taskExecutor 的专用线程池
.collect(Collectors.toList());

// 等待所有并发任务完成并合并结果
List<Event> allEvent = futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.collect(Collectors.toList());

return allEvent;
}

3. 事件清洗与去重 (Deduplication)

从LLM返回的事件可能存在范围重叠。系统通过一个去重算法,保留信息更完整(即文本范围更长)的事件。

代码示例:事件去重 (ReadifyStorylineService.java)

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
private List<Event> removeOverlappingEvents(List<Event> events) {
if (events == null || events.size() < 2) {
return events;
}
// 1. 按起始位置升序、结束位置降序排序
List<Event> sorted = events.stream()
.sorted(Comparator.comparingInt(Event::getStartChar)
.thenComparing(Comparator.comparingInt(Event::getEndChar).reversed()))
.collect(Collectors.toList());

List<Event> result = new ArrayList<>();
Event prev = null;
for (Event curr : sorted) {
if (prev == null || curr.getStartChar() >= prev.getEndChar()) {
// 2. 无重叠,直接保留
result.add(curr);
prev = curr;
} else {
// 3. 有重叠,保留范围更长的事件
int prevLen = prev.getEndChar() - prev.getStartChar();
int currLen = curr.getEndChar() - curr.getStartChar();
if (currLen > prevLen) {
// 用当前事件替换掉前一个(因为它更长)
result.set(result.size() - 1, curr);
prev = curr;
}
// 否则,curr事件被丢弃,因为它被prev包含了
}
}
return result;
}

4. 后续步骤

在提取出干净的事件列表后,系统会继续进行事件聚合、人物身份统一、故事主题生成等一系列处理,最终构建出完整的Story对象。

阶段四:状态轮询与结果获取

在后端进行复杂分析的同时,前端使用taskId轮询/storyline/status接口,获取ANALYZING_EVENTSSUCCEEDED等实时状态。当任务完成后,再通过/storyline/result接口获取最终的Story数据用于可视化。

阶段五:可视化呈现与交互式对话

前端拿到结构化的Story数据后,将其渲染成包含事件主视图、人物关系图谱等模块的可视化界面。更重要的是,用户可以点击任一事件或人物,进行上下文感知的智能问答,实现对故事的深度探索。

三、关键技术逻辑深潜

上述流程的顺畅运行,离不开几个核心的技术点。

1. 异步任务与状态管理:长时任务的优雅解决方案

  • CompletableFuture 任务编排:如submitNovelUploadTask代码所示,系统使用CompletableFuture将分析流程和文生图流程串联起来,形成一个非阻塞的异步任务链。thenCompose等方法优雅地定义了任务间的依赖关系。
  • ConcurrentHashMap 状态机taskStatusMaptaskResultMap作为轻量级的内存状态机,通过taskId进行快速读写,实现了任务状态的实时追踪。
  • 优雅的取消机制runningTasksMap存储了CompletableFuture对象本身。当用户请求取消任务时,系统可以直接调用其.cancel(true)方法来中断后台线程的执行。

2. “分而治之”的LLM分析链:从混沌到有序

  • 分治(Divide and Conquer):如extractEvents代码所示,系统先将大文本切分为可管理的小块(segmentText),并行处理(CompletableFuture.supplyAsync),最后再将结果合并。这不仅解决了上下文长度问题,还通过并行化大幅提升了处理效率。
  • 思维链(Chain of Thought) at System Level:整个分析过程模拟了人类的思考方式,逐步求精:
    1. 先看:解析文本,提取零散事件。
    2. 再理:聚合事件,构建层级关系。
    3. 后思:基于结构化数据,提炼主题。

这种多步骤、逐步增强上下文的LLM调用方式,能产出质量更高、更可靠的结果。

3. 数据模型:连接AI与UI的”翻译官”

  • **Event.java**:通过parentId字段巧妙地实现了树状层级结构,并通过startCharendChar将虚拟的事件锚定回原文的具体位置。
  • **Participant.java**:通过participantId实现了人物的身份统一,为构建人物关系网络奠定了基础。
  • **YAxisLayout.java**:这个数据结构非常精妙,它将LLM关于”如何展示”的建议固化为具体的数据模型,直接指导前端的渲染逻辑。

四、设计洞察与价值

  1. 健壮性与容错:在与LLM这种不确定性服务的交互中,系统设计了带指数退避的重试机制(在analyzeEventsWithLLM方法中体现)。当API调用失败或超时,系统不会立即崩溃,而是会多次尝试。

  2. 从静态分析到动态对话:系统最大的亮点在于,它没有停留在生成一份静态分析报告。故事线可视化界面本身就是一个巨大的、结构化的上下文信息库,使得后续的对话功能可以实现真正意义上的”深度漫游”。

  3. 可扩展性设计:尽管当前功能已经很完善,但其架构为未来的扩展留下了空间。例如,异步任务模型可以从单机内存版平滑迁移到基于Redis和消息队列的分布式版本,以支持更大的并发。

结语

我们详细拆解了一个LLM驱动的小说可视化系统,从宏观的架构到微观的算法,并深入到了核心代码的实现。它向我们展示了,在AIGC时代,真正的挑战和价值不仅在于模型本身,更在于如何围绕模型构建一个集异步编程、并发处理、AI工程、数据建模和容错设计于一体的复杂而健壮的应用系统。

从文字到画卷的旅程,是技术与艺术的结合,也是我们探索全新阅读体验的开始。