第2489篇:AI在会议和协作工具中的工程实践——会议纪要和行动项自动化
2026/4/30大约 7 分钟
第2489篇:AI在会议和协作工具中的工程实践——会议纪要和行动项自动化
适读人群:Java工程师、AI工程师、协作工具开发者 | 阅读时长:约14分钟 | 核心价值:实现会议录音转文字、自动生成纪要和行动项的完整工程链路
有段时间我们团队开会特别多,每周大大小小十几个会,每个会都要有人写会议纪要,写完发给参会者确认,参会者挑漏掉的内容,来来回回很麻烦。
最让人头疼的是,纪要里提到的"行动项"——"张三负责下周五之前完成 API 文档"——很多时候写了但没人追,到了下周五一问,说不知道这件事落到自己头上了。
会议纪要这件事,特别适合 AI 来做。
语音转文字现在准确率已经很高,结构化提取 Action Items 是 LLM 的强项,整个链路技术上是成熟的。难点在于工程实现的细节。
一、完整的技术链路
这里面有几个容易被忽视的技术细节:
说话人分离(Diarization)不是可选的,是必须的。没有说话人信息的转录文本,AI 无法正确理解谁说了什么,行动项的归属就会出错。
增量处理 vs 全量处理。在线会议可以实时转录,但纪要应该等会议结束再生成,不是实时生成的——因为很多会议结论是在最后几分钟才确定的,中途生成的纪要往往不完整。
二、语音转文字与说话人分离
@Service
@Slf4j
public class MeetingTranscriptionService {
private final WhisperApiClient whisperClient;
private final DiarizationService diarizationService;
// 处理会议录音文件,返回带说话人信息的转录
public TranscriptionResult transcribe(MeetingRecording recording) {
log.info("开始转录会议录音: {}, 时长: {}分钟",
recording.getMeetingId(), recording.getDurationMinutes());
try {
// 1. 语音转文字
WhisperResponse whisperResponse = whisperClient.transcribe(
WhisperRequest.builder()
.audioFile(recording.getAudioFilePath())
.language("zh") // 主要语言
.responseFormat("verbose_json") // 返回时间戳信息
.build()
);
// 2. 说话人分离(基于音频特征)
DiarizationResult diarization = diarizationService.process(
recording.getAudioFilePath(),
recording.getExpectedSpeakers() // 预期说话人数量,提高准确率
);
// 3. 合并:把说话人信息和文本时间戳对齐
List<TranscriptSegment> alignedSegments = alignSpeakersWithText(
whisperResponse.getSegments(),
diarization.getSpeakerTimelines()
);
// 4. 用会议参与者信息替换说话人 ID(SPEAKER_00 -> 张三)
List<TranscriptSegment> namedSegments = resolveSpeakerNames(
alignedSegments,
recording.getParticipants()
);
return TranscriptionResult.builder()
.meetingId(recording.getMeetingId())
.segments(namedSegments)
.fullText(buildFullText(namedSegments))
.durationSeconds(recording.getDurationSeconds())
.build();
} catch (Exception e) {
log.error("转录失败: {}", recording.getMeetingId(), e);
throw new TranscriptionException("转录失败", e);
}
}
// 对齐说话人时间线和文本时间戳
private List<TranscriptSegment> alignSpeakersWithText(
List<WhisperSegment> textSegments,
List<SpeakerTimeline> speakerTimelines) {
List<TranscriptSegment> result = new ArrayList<>();
for (WhisperSegment textSeg : textSegments) {
// 找到与文本时间段重叠最多的说话人
String speakerId = findDominantSpeaker(
textSeg.getStartTime(),
textSeg.getEndTime(),
speakerTimelines
);
result.add(TranscriptSegment.builder()
.startTime(textSeg.getStartTime())
.endTime(textSeg.getEndTime())
.speakerId(speakerId)
.text(textSeg.getText())
.build());
}
// 合并同一说话人的连续片段
return mergeConsecutiveSpeakerSegments(result);
}
private String findDominantSpeaker(
double start, double end, List<SpeakerTimeline> timelines) {
// 找到在 [start, end] 区间内说话时长最长的人
return timelines.stream()
.max(Comparator.comparingDouble(t ->
t.overlapDuration(start, end)))
.map(SpeakerTimeline::getSpeakerId)
.orElse("UNKNOWN");
}
// 用真实姓名替换说话人 ID
private List<TranscriptSegment> resolveSpeakerNames(
List<TranscriptSegment> segments,
List<Participant> participants) {
// 通过声纹匹配或会议入场顺序推断说话人身份
Map<String, String> speakerNameMap = buildSpeakerNameMap(segments, participants);
return segments.stream()
.map(seg -> seg.withSpeakerName(
speakerNameMap.getOrDefault(seg.getSpeakerId(), "参会者")))
.collect(Collectors.toList());
}
private List<TranscriptSegment> mergeConsecutiveSpeakerSegments(
List<TranscriptSegment> segments) {
if (segments.isEmpty()) return segments;
List<TranscriptSegment> merged = new ArrayList<>();
TranscriptSegment current = segments.get(0);
for (int i = 1; i < segments.size(); i++) {
TranscriptSegment next = segments.get(i);
if (next.getSpeakerId().equals(current.getSpeakerId())
&& next.getStartTime() - current.getEndTime() < 2.0) {
// 同一说话人且间隔小于2秒,合并
current = current.merge(next);
} else {
merged.add(current);
current = next;
}
}
merged.add(current);
return merged;
}
private String buildFullText(List<TranscriptSegment> segments) {
return segments.stream()
.map(s -> String.format("[%s] %s:%s",
formatTime(s.getStartTime()), s.getSpeakerName(), s.getText()))
.collect(Collectors.joining("\n"));
}
private String formatTime(double seconds) {
int minutes = (int) seconds / 60;
int secs = (int) seconds % 60;
return String.format("%02d:%02d", minutes, secs);
}
}三、AI 会议纪要生成
@Service
@Slf4j
public class MeetingMinutesGenerationService {
private final ChatClient chatClient;
// 生成结构化会议纪要
public MeetingMinutes generate(TranscriptionResult transcription,
MeetingContext context) {
log.info("生成会议纪要: {}", context.getMeetingId());
String transcriptText = transcription.getFullText();
// 长会议分段处理(超过 4000 字先分段摘要再合并)
if (transcriptText.length() > 8000) {
return generateForLongMeeting(transcription, context);
}
String prompt = buildMinutesPrompt(transcriptText, context);
String rawMinutes = chatClient.call(prompt);
return parseAndStructureMinutes(rawMinutes, context, transcription);
}
private String buildMinutesPrompt(String transcript, MeetingContext context) {
return String.format("""
请根据以下会议转录,生成专业的会议纪要。
会议信息:
- 会议名称:%s
- 会议时间:%s
- 参会人员:%s
- 会议议程:%s
会议转录:
%s
请生成包含以下部分的会议纪要(JSON格式):
{
"summary": "会议总结(3-5句话)",
"key_discussions": [
{"topic": "讨论主题", "content": "主要内容", "conclusion": "结论(如有)"}
],
"decisions": [
{"decision": "决策内容", "decision_maker": "决策者", "rationale": "决策依据"}
],
"action_items": [
{
"task": "任务描述",
"assignee": "负责人",
"due_date": "截止日期(如提到)",
"priority": "high/medium/low",
"context": "任务背景"
}
],
"follow_up_questions": ["需要后续跟进但未解决的问题"],
"next_meeting": "下次会议安排(如有提到)"
}
""",
context.getMeetingName(),
context.getMeetingTime().toString(),
String.join("、", context.getParticipantNames()),
context.getAgenda(),
transcript
);
}
// 长会议:分段处理
private MeetingMinutes generateForLongMeeting(
TranscriptionResult transcription, MeetingContext context) {
// 1. 将转录分成多段(按时间或话题边界切割)
List<String> chunks = splitTranscript(transcription.getFullText(), 4000);
// 2. 对每段生成摘要
List<String> chunkSummaries = chunks.stream()
.map(chunk -> chatClient.call("""
请简洁地总结以下会议片段的要点,保留关键决策和行动项:
""" + chunk))
.collect(Collectors.toList());
// 3. 合并摘要,生成完整纪要
String combinedSummary = String.join("\n\n---下一阶段---\n\n", chunkSummaries);
return generate(
TranscriptionResult.withText(combinedSummary),
context
);
}
private List<String> splitTranscript(String text, int maxChunkSize) {
List<String> chunks = new ArrayList<>();
String[] lines = text.split("\n");
StringBuilder currentChunk = new StringBuilder();
for (String line : lines) {
if (currentChunk.length() + line.length() > maxChunkSize) {
chunks.add(currentChunk.toString());
currentChunk = new StringBuilder();
}
currentChunk.append(line).append("\n");
}
if (!currentChunk.isEmpty()) {
chunks.add(currentChunk.toString());
}
return chunks;
}
private MeetingMinutes parseAndStructureMinutes(
String rawMinutes, MeetingContext context, TranscriptionResult transcription) {
try {
JsonNode node = objectMapper.readTree(rawMinutes);
List<ActionItem> actionItems = parseActionItems(node.get("action_items"));
return MeetingMinutes.builder()
.meetingId(context.getMeetingId())
.summary(node.path("summary").asText())
.keyDiscussions(parseDiscussions(node.get("key_discussions")))
.decisions(parseDecisions(node.get("decisions")))
.actionItems(actionItems)
.followUpQuestions(parseStringList(node.get("follow_up_questions")))
.nextMeeting(node.path("next_meeting").asText(null))
.generatedAt(Instant.now())
.rawTranscript(transcription.getFullText())
.build();
} catch (Exception e) {
log.error("解析会议纪要失败,返回原始文本", e);
return MeetingMinutes.rawTextFallback(context.getMeetingId(), rawMinutes);
}
}
}四、行动项自动追踪集成
@Service
@Slf4j
public class ActionItemTrackingService {
private final TaskManagementClient taskClient; // Jira/飞书任务等
private final NotificationService notificationService;
// 从会议纪要同步行动项到任务管理系统
public SyncResult syncActionItems(MeetingMinutes minutes) {
List<SyncResult.ItemResult> results = new ArrayList<>();
for (ActionItem item : minutes.getActionItems()) {
try {
// 1. 查找负责人在任务系统中的 ID
String assigneeId = userResolver.resolveByName(item.getAssignee());
if (assigneeId == null) {
log.warn("无法找到用户: {}", item.getAssignee());
results.add(SyncResult.ItemResult.failed(item, "用户不存在"));
continue;
}
// 2. 创建任务
Task task = taskClient.createTask(CreateTaskRequest.builder()
.title(item.getTask())
.description(String.format(
"来源:%s\n\n背景:%s\n\n会议纪要链接:%s",
minutes.getMeetingId(),
item.getContext(),
generateMinutesUrl(minutes.getMeetingId())
))
.assigneeId(assigneeId)
.dueDate(parseDueDate(item.getDueDate()))
.priority(mapPriority(item.getPriority()))
.labels(List.of("来自会议", "AI提取"))
.build());
// 3. 通知负责人
notificationService.send(Notification.builder()
.userId(assigneeId)
.title("会议行动项:" + truncate(item.getTask(), 50))
.content(String.format(
"在「%s」会议中,你有一个行动项需要跟进:\n%s\n\n截止:%s",
minutes.getMeetingId(),
item.getTask(),
item.getDueDate() != null ? item.getDueDate() : "未指定"
))
.actionUrl(task.getUrl())
.build());
results.add(SyncResult.ItemResult.success(item, task.getId()));
} catch (Exception e) {
log.error("同步行动项失败: {}", item.getTask(), e);
results.add(SyncResult.ItemResult.failed(item, e.getMessage()));
}
}
return SyncResult.builder()
.meetingId(minutes.getMeetingId())
.totalItems(minutes.getActionItems().size())
.successItems(results.stream().filter(SyncResult.ItemResult::isSuccess).count())
.failedItems(results.stream().filter(r -> !r.isSuccess()).count())
.itemResults(results)
.build();
}
private String truncate(String text, int maxLength) {
return text.length() <= maxLength ? text : text.substring(0, maxLength) + "...";
}
private LocalDate parseDueDate(String dueDateText) {
if (dueDateText == null || dueDateText.isBlank()) return null;
// 解析各种日期格式:下周五、2024-01-31、明天等
// ... 实现省略,实际用 AI 或规则解析
return null;
}
}五、质量控制与人工审核流程
AI 生成的纪要和行动项需要人工审核才能发送:
@Service
public class MinutesReviewWorkflowService {
// 发起审核流程
public ReviewTask submitForReview(MeetingMinutes minutes, String submitterId) {
// 低置信度内容标记需要重点审核
List<ReviewFlag> flags = identifyLowConfidenceItems(minutes);
ReviewTask reviewTask = ReviewTask.builder()
.minutes(minutes)
.submitterId(submitterId)
.reviewerId(determinePrimaryReviewer(minutes))
.flags(flags)
.deadline(Instant.now().plusMinutes(30))
.status(ReviewStatus.PENDING)
.build();
// 如果没有 flags,可以选择跳过审核直接发送
if (flags.isEmpty() && minutes.isDraftModeDisabled()) {
autoApprove(reviewTask);
} else {
notifyReviewer(reviewTask);
}
return reviewTask;
}
private List<ReviewFlag> identifyLowConfidenceItems(MeetingMinutes minutes) {
List<ReviewFlag> flags = new ArrayList<>();
// 没有明确截止日期的行动项
minutes.getActionItems().stream()
.filter(item -> item.getDueDate() == null)
.forEach(item -> flags.add(ReviewFlag.of(
"行动项缺少截止日期", item.getTask(), ReviewFlag.Severity.MEDIUM)));
// 无法解析到具体用户的行动项
minutes.getActionItems().stream()
.filter(item -> !userResolver.canResolve(item.getAssignee()))
.forEach(item -> flags.add(ReviewFlag.of(
"无法识别负责人:" + item.getAssignee(), item.getTask(), ReviewFlag.Severity.HIGH)));
return flags;
}
private String determinePrimaryReviewer(MeetingMinutes minutes) {
// 会议组织者作为主要审核人
return minutes.getOrganizerId();
}
}会议 AI 这个方向,是我见过投入产出比很高的 AI 应用之一。不需要复杂的 Agent 架构,不需要实时推理,一个稳定的异步处理流水线,就能显著降低团队的沟通成本。
