第2233篇:实时会议AI系统——边开会边出纪要的工程实现
第2233篇:实时会议AI系统——边开会边出纪要的工程实现
适读人群:做企业协作工具、视频会议系统、办公效率工具的工程师 | 阅读时长:约17分钟 | 核心价值:从产品侧的真实需求出发,完整实现一套实时会议摘要和行动项提取系统
有个真实的故事。
公司推行AI会议纪要系统,第一版方案是:会议结束后,上传录音,等10-20分钟,系统生成纪要。
产品上线后,发现几乎没人用。
调研用户,结论出人意料:不是功能不好,是时间太晚了。"会议结束20分钟后出纪要,那时候大家已经散了,行动项谁来接都不知道了。我们需要的是会议进行中就能看到关键结论。"
这个反馈让我意识到,会议AI的真正价值不是事后转录,而是在会议进行时就帮大家提炼信息。随即把方案从"事后处理"改成"实时处理"。
从事后处理到实时处理,看起来只是时间提前了,但工程复杂度完全不同。
实时会议AI的架构挑战
事后处理和实时处理的核心区别:
事后处理:
录音文件 → 完整转录 → 分析 → 输出纪要
优点:准确率高(有完整上下文)
缺点:延迟高(会议结束后才有结果)
实时处理:
持续音频流 → 增量转录 → 增量分析 → 实时更新
挑战:
1. 增量摘要:没有完整文本,如何生成摘要?
2. 说话人分离:多人同时说话,谁说的什么?
3. 状态管理:会议中途加入的人如何同步历史?
4. 准确性:错误的实时识别如何在后续修正?系统架构
核心组件1:实时转录与说话人分离
/**
* 实时会议转录服务
* 多路音频流 → 合并转录 → 说话人标注
*/
@Service
@Slf4j
public class MeetingTranscriptionService {
private final StreamingASRClient asrClient;
private final SpeakerDiarizationService diarization;
private final MeetingSessionRepository sessionRepo;
/**
* 处理单个参会者的音频流
*
* @param meetingId 会议ID
* @param participantId 参会者ID
* @param audioStream 音频字节流
*/
public void processParticipantAudio(
String meetingId,
String participantId,
Flux<byte[]> audioStream) {
MeetingSession session = sessionRepo.getOrCreate(meetingId);
String participantName = session.getParticipantName(participantId);
// 流式ASR处理
asrClient.streamRecognize(audioStream)
.filter(result -> result.isFinal() && !result.getText().isBlank())
.subscribe(
result -> handleTranscription(session, participantId, participantName, result),
error -> log.error("转录错误: meetingId={} participantId={}",
meetingId, participantId, error)
);
}
private void handleTranscription(
MeetingSession session,
String participantId,
String participantName,
ASRResult result) {
TranscriptSegment segment = TranscriptSegment.builder()
.segmentId(UUID.randomUUID().toString())
.meetingId(session.getMeetingId())
.speakerId(participantId)
.speakerName(participantName)
.text(result.getText())
.startTime(result.getStartTime())
.endTime(result.getEndTime())
.confidence(result.getConfidence())
.createdAt(Instant.now())
.build();
// 保存到数据库
sessionRepo.addSegment(segment);
// 触发增量摘要更新
session.addPendingSegment(segment);
log.debug("新转录片段: speaker={} text='{}'",
participantName, result.getText());
}
}核心组件2:增量摘要——边说边更新
这是实时会议AI最难的部分。
传统摘要方式是等全文出来再总结。实时场景下,会议正在进行,我们只有"到目前为止说的内容",但我们需要随时给用户看当前的摘要。
关键技术思路:滑动窗口 + 增量更新
- 不是每来一句话就重新总结全文(太贵,太慢)
- 也不是完全等会议结束(太晚)
- 而是每积累一定量的新内容(比如5分钟),做一次增量摘要更新
/**
* 增量会议摘要服务
* 基于滑动窗口实现实时摘要更新
*/
@Service
@Slf4j
public class IncrementalSummaryService {
private final LLMClient llmClient;
private final MeetingSessionRepository sessionRepo;
// 积累多少新内容触发一次摘要更新
private static final int SEGMENTS_PER_UPDATE = 15;
// 每次摘要使用的滑动窗口大小(最近N条转录)
private static final int CONTEXT_WINDOW = 50;
/**
* 检查是否需要更新摘要,如果需要则异步更新
*/
@Async
public void maybeUpdateSummary(String meetingId) {
MeetingSession session = sessionRepo.get(meetingId);
if (session == null) return;
int pendingCount = session.getPendingSegmentsCount();
if (pendingCount < SEGMENTS_PER_UPDATE) {
return; // 积累的新内容不够
}
// 清空待处理队列(原子操作)
List<TranscriptSegment> pendingSegments = session.drainPendingSegments();
// 获取最近的上下文窗口
List<TranscriptSegment> contextSegments = sessionRepo.getRecentSegments(
meetingId, CONTEXT_WINDOW);
updateSummary(session, contextSegments, pendingSegments);
}
private void updateSummary(
MeetingSession session,
List<TranscriptSegment> context,
List<TranscriptSegment> newSegments) {
// 格式化转录文本
String transcriptText = formatTranscript(context);
String existingSummary = session.getCurrentSummary();
String prompt = String.format("""
你是一个会议助手,正在实时整理会议纪要。
【现有摘要】(到目前为止的会议总结)
%s
【最新对话内容】
%s
请基于以上内容,更新并输出完整的会议纪要。包括:
## 议题与讨论
[主要讨论的话题和要点,按时间顺序]
## 关键决策
[已达成的共识和决定]
## 待确认事项
[还在讨论中、未有结论的问题]
## 行动项
[格式:- [负责人] 要做的事情 (截止时间,如有)]
注意:
1. 保留现有摘要中的完整内容,在其基础上补充更新
2. 如果最新对话修改了之前的结论,更新相应内容
3. 行动项只记录明确分配了负责人的事项
""",
existingSummary.isEmpty() ? "(会议刚开始,暂无摘要)" : existingSummary,
transcriptText
);
LLMResponse response = llmClient.call(LLMRequest.of(prompt));
String newSummary = response.getContent();
// 更新会话摘要
session.updateSummary(newSummary);
sessionRepo.save(session);
// 通知所有参会者摘要已更新(通过WebSocket推送)
publishSummaryUpdate(session.getMeetingId(), newSummary);
log.info("会议摘要已更新: meetingId={} length={}",
session.getMeetingId(), newSummary.length());
}
private String formatTranscript(List<TranscriptSegment> segments) {
return segments.stream()
.map(s -> String.format("[%s] %s: %s",
formatTime(s.getStartTime()),
s.getSpeakerName(),
s.getText()))
.collect(Collectors.joining("\n"));
}
}核心组件3:行动项提取
行动项是会议纪要里最关键的输出,但也是最难准确提取的。
难点在于:会议中"我去做X"这句话,可能是在聊无关的事,也可能是真正的行动承诺。
工程上的处理方式:先提取候选行动项,再让模型判断是否是真正的承诺。
/**
* 行动项提取服务
*/
@Service
public class ActionItemExtractor {
private final LLMClient llmClient;
/**
* 从转录文本中提取行动项
*/
public List<ActionItem> extract(List<TranscriptSegment> transcript) {
String transcriptText = transcript.stream()
.map(s -> String.format("%s(%s):%s",
s.getSpeakerName(),
formatTime(s.getStartTime()),
s.getText()))
.collect(Collectors.joining("\n"));
String prompt = String.format("""
请从以下会议转录中提取行动项。
行动项的判断标准:
- 明确说出要做某件事("我去、我来、我负责、我会、我明天"等)
- 有明确的负责人(说话的人,或被提到的人)
- 是具体的任务,不是笼统的讨论
以下不算行动项:
- 假设性的讨论("如果...可以...")
- 已经完成的事情
- 纯粹的观点表达
转录内容:
%s
请以JSON数组格式返回行动项,每个行动项包含:
- owner: 负责人姓名
- action: 要做的事情(简洁描述)
- deadline: 截止时间(如果提到了,否则null)
- priority: 优先级(high/medium/low,根据语境判断)
- sourceText: 原文片段(让用户核实)
""", transcriptText);
LLMResponse response = llmClient.call(LLMRequest.of(prompt));
return parseActionItems(response.getContent());
}
}会议结束后的最终整理
实时摘要质量相对粗糙(受限于滑动窗口)。会议结束后,用完整转录做一次最终整理。
/**
* 会议结束后的最终纪要生成
*/
@Service
public class FinalMinutesGenerator {
private final LLMClient llmClient;
public MeetingMinutes generate(String meetingId, List<TranscriptSegment> fullTranscript) {
String fullTranscriptText = formatFullTranscript(fullTranscript);
String prompt = String.format("""
请为以下会议生成正式的会议纪要。
要求:
1. 结构清晰(参会人员、会议时间、议题、讨论要点、决策、行动项)
2. 语言正式、简洁
3. 行动项必须有负责人和时间节点(如果讨论中有提到)
4. 争议性观点要如实记录双方意见
会议转录(完整版):
%s
""", fullTranscriptText);
LLMResponse response = llmClient.call(LLMRequest.builder()
.prompt(prompt)
.maxTokens(2000) // 完整纪要可能较长
.build());
return MeetingMinutes.builder()
.meetingId(meetingId)
.content(response.getContent())
.generatedAt(Instant.now())
.build();
}
}与企业工具集成
会议纪要生成了,最大的价值是能自动流转到工作系统:
/**
* 会议结果分发服务
* 自动把行动项同步到任务管理系统
*/
@Service
public class MeetingResultDistributor {
private final JiraClient jiraClient;
private final FeishuClient feishuClient;
private final EmailService emailService;
public void distribute(MeetingMinutes minutes, List<ActionItem> actionItems) {
// 1. 在飞书/企业微信发会议纪要
feishuClient.sendGroupMessage(
minutes.getMeetingGroupId(),
formatForFeishu(minutes)
);
// 2. 在Jira创建任务
actionItems.stream()
.filter(item -> item.getPriority() == Priority.HIGH)
.forEach(item -> {
jiraClient.createTask(JiraTask.builder()
.summary(item.getAction())
.assignee(resolveJiraUser(item.getOwner()))
.dueDate(item.getDeadline())
.description("来自会议:" + minutes.getMeetingTitle())
.labels(List.of("meeting-action-item"))
.build());
});
// 3. 给每个有行动项的人单独发邮件
actionItems.stream()
.collect(Collectors.groupingBy(ActionItem::getOwner))
.forEach((owner, items) -> {
emailService.send(Email.builder()
.to(resolveEmail(owner))
.subject("会议行动项提醒 - " + minutes.getMeetingTitle())
.body(formatActionItemEmail(owner, items, minutes))
.build());
});
}
}一些教训
教训1:不要追求实时准确,要追求实时有用
实时摘要不可能像人工整理的那样准确。但用户需要的不是100%准确,而是在会议进行中就能看到"大概讨论了什么、有哪些待确认"。80分的实时摘要比100分的事后摘要更有价值。
教训2:给用户编辑权
AI生成的行动项不一定准确。必须让用户能在会议中随时修改、添加、删除行动项。会议AI是辅助,不是替代。
教训3:隐私敏感场景要告知
会议内容涉及商业机密。系统上线前,必须明确数据存储和使用策略,会议开始时提醒参会者录音。
