无权限

Derrick博客站

12、

首届"昆仑杯"工业软件创新挑战大赛 - 答辩问答预备

蛋肠小队 - 工业MiniApp

共 6 大类 30 个问答对,所有回答均基于代码实际实现

一、工序数据模型与JSON字段冗余

Q1: 演讲稿中提到"JSON快照冗余存储",具体是怎么实现的?

答: 准确地说,我们不是对整个工序对象做快照,而是在工序实体上冗余存储了关联设备的ID列表JSON字段。具体来说,WorkingProcedure 实体有三个JSON字符串字段:

  • productionEquipment:存储生产设备ID的JSON数组,如 "[12345, 67890]"

  • inspectionEquipment:存储检验设备ID的JSON数组,同样格式

  • equipmentJson:存储完整设备数据的JSON字符串

创建时ProcedureService.serializeEquipmentList() 方法将前端传入的设备列表合并去重,通过 dualKeyMapper.idByCode() 将设备编码解析为数字ID,再用 objectMapper.writeValueAsString() 序列化为JSON字符串写入。

查询时,采用三级降级策略获取设备数据:

  1. 优先:通过 Procedure_Equipment_Rel/queryRelationship 查物理关联关系

  2. 降级:解析 productionEquipment / inspectionEquipment JSON字段提取ID列表

  3. 兜底:解析 equipmentJson 字段提取设备编码和ID

最后用 categorizeEquipments() 方法将设备列表按ID匹配分为生产设备和检验设备返回给前端。

列表页使用 WorkingProcedureQueryViewDTO,只包含基础字段(编码、名称、工时等),不加载设备数据,避免了N+1查询。


Q2: 为什么列表页不加载设备数据?前端如何处理?

答: 列表页的 WorkingProcedureQueryViewDTO 只映射了 idprocedureCodenamedefaultDurationoperatorproductionStepsprocedureDesc 这些基础字段。如果列表页要加载每个工序的关联设备,就需要对每条记录都查询 Procedure_Equipment_Rel 关系表,产生N+1查询问题。

我们的做法是:列表页只展示基础信息,用户点击某个工序进入详情页时,才触发一次完整的设备查询(三级降级策略)。这样列表页的API响应时间是O(1)级别的,而不是O(N)。


Q3: 设备JSON字段更新时如何保持一致性?

答:ProcedureService.update() 方法中(第875-885行),更新前会先从xDM-F获取现有实体,检查 equipmentJson 字段是否存在。如果请求中没有携带该字段,则将原有的 equipmentJson 保留到更新参数中,避免被覆盖。

设备关联关系的变更通过 maintainProcedureEquipmentRels() 方法处理,在关系维护完成后,调用 updateProcedureEquipmentJsonField() 方法重新收集所有关联设备ID,序列化为JSON字符串后回写到对应的分类字段(productionEquipmentinspectionEquipment)。

也就是说:关系表是主数据,JSON字段是从属冗余,每次关系变更后自动同步。


二、AI Agent 架构细节

Q4: Agent的类继承链具体是怎么设计的?每层的抽象边界在哪?

答: 继承链是 BaseAgentReActAgentToolCallAgentMiniappAgent

BaseAgent(基础设施层):

  • 定义状态机:IDLE → RUNNING → FINISHED/ERROR,支持自动重置

  • 提供 run() 同步执行和 runStream() 异步流式执行(CompletableFuture.runAsync + SseEmitter,5分钟超时)

  • 内置 messageList 消息上下文,通过 truncateMessageList() 基于 token 预算(14000)自动截断最旧消息

  • runStream() 包含 SSE 连接断开检测(捕获 SseSendException),断开后立即终止循环

ReActAgent(范式层):

  • 抽象类,定义 think() 返回 boolean(true=需要行动,false=思考完成)、act() 返回 String

  • step() 方法实现"先想后做":调用 think(),若返回 false 则 FINISHED,否则调用 act()

ToolCallAgent(实现层):

  • think() 的具体实现:截断消息列表 → 构造 Prompt → 调用 LLM → 对 tool_calls 做去重(ToolCallDedupeUtil)→ 判断是否有 tool_calls

  • thinkStreamReal() 流式思考:使用 chatClient.stream().chatResponse() 逐 token 推送 SSE,CountDownLatch 等待完成(60秒超时)

  • act() 的具体实现:ToolCallingManager.executeToolCalls() 执行工具 → 更新消息列表 → 检查 terminate → processFollowUpIfNeeded() 检查后置处理器

MiniappAgent(业务层,原型作用域Bean):

  • 设置系统提示词、maxSteps=20、maxMessageListTokens=16000

  • runStreamVercelFormat() 是主入口:消息预处理 → 意图识别 → 工具路由 → ReAct循环 → Vercel格式SSE输出


Q5: 意图识别只取最后一条消息,不会丢失上下文吗?

答: 不会。意图识别(recognizeIntent())确实只取最后一条 UserMessage 送给 LLM,但这有意识的设计——意图识别的目标是判断当前这条请求要做什么操作,而不是理解完整的对话历史。

比如用户先说"查一下EQ-0001",然后说"把它改成停机状态"。意图识别只需要看最后一条"把它改成停机状态",就能判断为 UPDATE 意图。

而对话历史的上下文是在后续的 ReAct 循环中通过 messageList 保持的——ToolCallAgent.think() 构造 Prompt 时会包含完整的多轮历史消息(经过 token 预算截断)。所以意图识别负责"快速分类",ReAct 循环负责"带上下文的深度推理",两者各司其职。


Q6: 意图识别的LLM调用失败了怎么办?有什么兜底机制?

答:parseIntentResponse() 方法中,如果正则提取 标签失败(LLM返回格式异常、超时等),会捕获异常并默认返回 NEED_TOOL。这意味着识别失败时,系统会走完整的工具调用流程,而不是直接报错。

同时 mapIntentToToolCategory() 也会做兜底:如果意图字符串不匹配任何已知枚举值,返回 GENERAL 类别,此时会回退到传入全部工具。


Q7: terminate工具的实现极其简单(只返回"任务结束"),为什么这样设计?

答: TerminateTool 的实现确实只有一行:return "任务结束"。但它的作用不在执行逻辑,而在信号机制

ToolCallAgent.act() 方法中,执行完工具后会检查:如果本轮调用的工具名称等于 "terminate",就将 Agent 状态设为 FINISHED,终止 ReAct 循环。

为什么不用其他方式终止?因为 LLM 的输出是不可预测的。通过工具调用来终止,是让 LLM 显式表达"我已经完成了"的最可靠方式——它需要主动调用 terminate,而不是我们猜测它说完了。系统提示词中也明确规定"查询完成后必须调用terminate",并在每轮都注入这个工具。


Q8: 工具调用去重是怎么做的?为什么LLM会产生重复调用?

答: ToolCallDedupeUtil.dedupeParallelToolCalls() 方法按 (工具名 + "\0" + 规范化参数JSON) 做去重。参数规范化通过 canonicalArgumentsJson() 实现,将JSON键递归排序后比较语义等价性。

LLM产生重复调用的原因主要有两个:一是并发 tool_calls 时,LLM可能对同一操作返回多个相同的调用(特别是使用较便宜的模型时);二是某些 prompt 工程技巧(如"请调用工具查询所有相关数据")可能导致LLM过度积极地调用同一工具。

去重后如果 tool_calls 数量变少,dedupeChatResponseToolCallsIfNeeded() 会重建 ChatResponse 对象,确保后续流程拿到的是干净的数据。


Q9: processFollowUpIfNeeded() 后置处理器是怎么工作的?

答: ResultHandlerRegistry 维护了一个 工具名 → ResultHandler 的映射。工具执行完成后,processFollowUpIfNeeded() 遍历 toolResponseMessage 的每个 response,查找是否有注册的 handler。

如果 handler 返回 FollowUp(包含建议工具名、参数、任务进度提示),系统不会直接执行后续工具,而是通过 enrichToolResultWithContext() 将任务上下文追加到最后一个 ToolResponseMessage 的 responseData 中。这样 LLM 在下一轮 think 时,会自然看到"上一步完成了,可能需要做 XXX"的提示,由 LLM 自主决定是否执行。

这个设计的精妙之处是:不强制执行后续操作,而是通过信息注入让 LLM 自主决策,保持了 Agent 的灵活性。


Q10: TokenEstimator的估算公式是什么?为什么用启发式而不是精确计算?

答: TokenEstimator 的估算公式是:

  • 中文字符:每个 2.0 tokens

  • 其他字符:每 3.5 个字符 1 token

  • 每条消息固定开销:4.0 tokens

使用启发式估算而非精确 tokenizer(如 tiktoken)的原因:一是性能,精确 tokenizer 需要加载模型文件(几MB),在每次消息截断时都要运行,延迟不可接受;二是足够准确,我们的场景主要是中文工业对话,2.0 tokens/中文字符的估算与 DashScope 的实际 tokenizer 偏差在 10% 以内;三是零依赖,不需要引入额外的 tokenizer 库。

truncateToTokenBudget() 从最旧消息开始逐条丢弃,直到总 token 在预算内,且始终保留最新一条消息。


三、RAG 知识库深入

Q11: RAG检索的四级流程(主检索→词法补充→降级阈值→邻块扩展)具体是怎么工作的?

答: MiniAppLoggingFallbackDocumentRetriever 实现了四级检索:

第一级,主检索:标准的 PgVector 向量相似度搜索,使用 topK(默认6)和 similarityThreshold(默认0.55),如果分类非空则加 metadata 过滤。

第二级,词法补充:如果主检索结果不足 topK 个,提取查询中的实体 token(PART-xxx、VMC-xxx等),对 vector_store 表做 ILIKE 模糊匹配,按文档ID去重后补入结果集。这一步解决了向量检索对精确编码(如设备编号)匹配不好的问题。

第三级,降级阈值:如果主检索返回0个结果且降级开关开启,用更低的 similarityFallbackThreshold 重试一次。这处理了查询与知识库内容表述差异较大的情况。

第四级,邻块扩展:对每个命中的文档,按 filename + section_indexchunk_index 查找相邻块(前一个/后一个),相似度阈值设为0.0(即只要存在就召回),最多扩展 neighborExpandMax 个。这保证了语义片段的上下文完整性。

每一级都输出结构化日志,包含阶段名、查询长度、topK、阈值、命中数,以及每个命中的文档ID、文件名、分类、距离、相似度等元信息。


Q12: 短查询(≤22字符)为什么跳过LLM重写?阈值22是怎么来的?

答: 短查询通常已经是关键词形式,比如"EQ-0001 状态"或"PL01工艺参数"。用LLM重写的风险是:可能引入无关词汇稀释核心实体的权重,或改写后丢失关键编码(如EQ-0001变成"设备1号")。

阈值22是经验值——22个中文字符大约是一个简短工业查询的长度(设备编码6-8字符 + 空格 + 1-2个领域词),超过这个长度通常意味着用户在用口语化的方式描述需求,此时LLM重写能去掉"帮我查一下""请问"等口语成分,补充领域同义词。

短查询跳过重写后,仍然会通过 RagQueryEntityExtractor 提取高信号实体拼接到检索语句中,增强精确匹配的召回率。


Q13: PDF文档解析的双层策略是什么?为什么需要两套解析器?

答: PDF解析采用双层策略:

首选:PDFBox 逐页提取PdfPageTextExtractor)。使用 PDFTextStripper 逐页提取文本,每页生成一个 Document,携带 page 元数据。跳过不足3个字符的页面(通常是空白页)。优势是保留页码信息,便于后续按页引用。

降级:Apache Tika 全文提取。当 PDFBox 失败时(如某些加密PDF或特殊编码),回退到 Tika 的 PDFParser。它提取全文并附带 Author、创建时间、修改时间等丰富元数据。但会丢弃页码信息,且扫描件(<50字符)会被拒绝。

需要两套是因为工业文档格式多样——设备维护手册通常是标准PDF(PDFBox处理良好),但供应商提供的技术规格书可能是特殊格式,Tika 的兼容性更广。两者互补,确保99%的工业文档都能被解析。


Q14: Markdown文档的分块策略是什么?最小50字符、最大5000字符的依据是什么?

答: MarkdownDocumentReader 的分块策略是级联回退

  1. 优先按水平分割线---***)分块

  2. 其次按标题### 等)分块

  3. 最后按段落(空行)分块

分块后:

  • 低于 minSectionLength(50字符)的块被丢弃——太短的片段(如单独一行标题或分割线)没有检索价值

  • 超过 maxSectionLength(5000字符)的块按行边界二次切分——避免单个块过大导致向量表示稀释

50字符的下限是为了过滤噪音片段。5000字符的上限基于一个经验:DashScope 的 text-embedding-v3 模型在 512 token 左右的输入上效果最好,5000个中文字符约2500 token,在可接受范围内。太大则向量表示会过于笼统,检索精度下降。


Q15: 向量入库后如何验证数据确实写入了?

答: DocumentUploadService.verifyVectorRowsForOriginalFilename() 方法在入库完成后执行验证查询:SELECT COUNT(*) FROM vector_store WHERE metadata->>'filename' = ?。如果返回0,说明写入失败(可能是向量维度不匹配、metadata字段缺失等),会抛出异常通知用户。

这个验证是在每批10条入库后执行的,不是全部入库后一次性检查,所以能精确定位失败的批次。


四、SSE流式通信

Q16: 后端有两套SSE流式路径,它们的区别和适用场景是什么?

答:

路径A:RAG对话流式/ai/mini_app/chat/rag/sse

  • 使用 SseEmitter + CompletableFuture.runAsync

  • 流程:查询分类 → 查询重写 → 发送 thinking 事件 → 订阅 Flux 逐 chunk 发送 message 事件 → 完成后持久化对话 → 发送 terminate 事件

  • 事件格式简单:thinking(JSON)、message(纯文本)、error(JSON)、terminate(JSON)

  • 适用场景:知识库问答,不需要工具调用

路径B:Agent工具调用流式/ai/miniappagent/chat

  • 同样使用 SseEmitter + CompletableFuture.runAsync

  • 流程:发送 ping 连接检测 → ReAct循环(每轮:think-start/think-content/think-end → tool-call → tool-result)→ terminate

  • 事件格式丰富:通过 SseEventSender 发送结构化事件,每个事件带自增ID、时间戳、步骤号

  • 适用场景:需要调用业务工具的复杂操作

路径C:Vercel兼容格式/chat/completions

  • MiniappAgent.runStreamVercelFormat() 直接输出 OpenAI SSE 格式的 chat.completion.chunk

  • 兼容 OpenAI SDK 的 choices[0].delta.content / delta.tool_call 结构

  • 适用场景:前端使用 Vercel AI SDK 的 useChat 接入


Q17: SseSendException是怎么实现连接断开检测的?

答: SseEventSender.sendEvent() 方法在调用 emitter.send() 时,如果底层TCP连接已断开,会抛出 IOException。我们将其包装为自定义的 SseSendException(继承 RuntimeException)。

BaseAgent.runStream() 的 ReAct 循环中,每一步执行前后都会检查这个异常。一旦捕获到 SseSendException,立即跳出循环,终止Agent执行。这避免了前端已关闭但后端还在白白消耗计算资源的问题。

另外,在 emitter.complete() 之前有一个 Thread.sleep(30) 的小延迟,是为了确保最后一个 SSE 事件被完全刷新到TCP缓冲区后再关闭连接。


Q18: 前端SSE的两套适配器为什么需要并存?

答:

chat-adapter.ts(基于 EventSource):

  • 原生 GET 请求,支持浏览器自动重连(最多2次,间隔1秒)

  • 双模式:RAG端点用命名事件监听(addEventListener('thinking')addEventListener('message')),Agent端点用 onmessage 统一处理

  • 适配自定义SSE格式(think-start/content/end、tool-call、tool-result等)

vercel-adapter.ts(基于 ReadableStream):

  • POST 请求,使用 fetch + ReadableStream.getReader() 手动解析SSE

  • 维护 lineBuffer 处理跨TCP包的不完整行

  • 用 async generator(async function*)实现流式消费,yield 每个解析成功的JSON对象

  • 兼容 OpenAI Vercel 格式的 chat.completion.chunk

两者通过 useChat composable 统一桥接:for await...of 消费流,AbortController 中止,数组替换确保 Vue 响应式更新。并存的原因是协议格式不同——自定义格式和 OpenAI 格式的数据结构差异很大,强行统一会增加解析复杂度。


五、确认流程与安全机制

Q19: 操作令牌(operationToken)的格式是什么?安全性如何保证?

答: Token 格式为 {UUID}_{toolName}_{timestamp},存在内存 ConcurrentHashMap 中。

安全性保证有四层:

  1. 不可预测:UUID v4 部分是随机生成的,无法枚举

  2. TTL过期ConfirmationContextDEFAULT_TTL_MS 为10分钟,getContext() 会自动检查并移除过期条目

  3. 工具名匹配confirm() 时会验证 token 对应的 toolName 与 executor 注册的工具名是否一致,防止用A操作的token去执行B操作

  4. 一次性消费confirm() 执行后立即从 contextStoreremove,同一个 token 不能重复使用

不足之处是:token 存在内存中,不支持分布式部署。生产环境需要改为 Redis 存储。


Q20: 哪些工具注册了确认流程?确认执行时完全绕过LLM吗?

答: 注册了确认流程的工具包括:PartUpdateToolPartUpdateStatusToolPartBomUpdateToolEquipmentUpdateToolEquipmentUpdateStatusToolPartBatchUpdateTool

确认执行完全绕过LLM。前端用户点击确认后,直接调用 POST /api/confirm/execute,请求到达 ConfirmController.execute()ConfirmationExecutorRegistry.execute()ConfirmationService.confirm()executor.execute(parameters)

这个设计的核心价值是:操作的参数在 preview 阶段就已经确定并存储,confirm 阶段只是用这些已确定的参数执行实际操作。LLM 不参与确认过程,消除了"Agent意外修改参数"的风险。


Q21: 设备状态变更时的引用检查具体查的是什么?

答: EquipmentService.checkIfEquipmentReferenced() 方法查询 Procedure_Equipment_Rel/queryRelationship,以设备ID为 target(即"谁引用了这个设备"),setLatestOnly(false) 检查所有版本的引用关系。

只要有任何一条工序引用了该设备(无论是哪个版本),就抛出 BusinessException(EQUIPMENT_REFERENCED),拒绝状态变更为 STOPPEDSCRAPPED

这个检查在三个地方被调用:

  • updateStatus():状态改为停机/报废时

  • update():设备基本信息更新中如果包含状态变更

  • delete():删除设备时

要修改设备状态为停机/报废,用户必须先解除所有工序对该设备的引用。


六、前端画布与交互

Q22: 无限画布的坐标转换是怎么做的?拖拽节点时如何避免误触?

答: 画布使用 view 对象管理 {offsetX, offsetY, scale}applyView()world 元素的 CSS transform 设为 translate(offsetX, offsetY) scale(scale),transform-origin 为 0 0

坐标转换公式worldX = (clientX - rect.left - view.offsetX) / view.scale,将屏幕坐标转为世界坐标。

平移:画布空白处 mousedown 设置 isPanning=true,mousemove 中累加 dx/dy 到 view.offsetX/offsetY

缩放:滚轮事件,deltaY > 0 缩小(×0.9),否则放大(×1.1),scale 限制在 [0.3, 3]

拖拽防误触:节点 mousedown 时记录 {x, y, time},mousemove 中计算移动距离,只有超过 5px 阈值才启动 startDragNode()。这区分了"点击选中节点"和"拖拽移动节点"两种操作。拖拽时通过 (clientX - dragStart) / view.scale 计算增量,确保在不同缩放比例下拖拽速度一致。


Q23: 画布中连接线的绘制逻辑是什么?支持分支和合并吗?

答: renderLinks() 方法按 nodes 数组顺序,相邻节点之间绘制 SVG

  • 起点:前一个节点的右侧中心 (from.x + 220, from.y + 40)

  • 终点:后一个节点的左侧中心 (to.x, to.y + 40)

  • 带箭头标记 marker-end="url(#arrow)"

当前版本的连接线是按拖入画布的顺序自动串联的(第1个→第2个→第3个...),是线性排列。演讲稿中提到的链表分支/合并能力,是后端数据模型层面的设计——后端的工艺路线数据结构支持一个工序指向多个下个工序(分支)和多个工序指向同一个下个工序(合并),但前端画布的可视化连线目前是简化版本。


Q24: "划词问AI"功能如何处理input/textarea中的选区?

答: TextSelectionToolbar.vue 在捕获阶段监听 mouseup/touchend。常规文本选区通过 window.getSelection().getRangeAt(0).getBoundingClientRect() 获取矩形坐标。

input/textarea 的特殊处理:这些元素的选区矩形宽高可能为 0(浏览器行为)。此时回退到两个备选方案:

  1. 使用 document.activeElementgetBoundingClientRect() 获取输入框位置

  2. 使用选区 startContainer.parentElement 的位置

定位逻辑:默认显示在选区左侧(rect.left - toolbarWidth - 8),空间不足时切换到右侧,上下边界溢出时裁剪。

复制功能:优先使用 navigator.clipboard.writeText(现代API),不支持时回退到 document.execCommand('copy')(创建临时 textarea 复制后销毁)。


Q25: AiAgentStatusStrip如何实时展示Agent的思考和工具调用过程?

答: AiAgentStatusStrip.vue 根据 agentPhase prop 渲染不同状态卡片,每种状态有独立的图标、文案、颜色方案和动画:

阶段图标文案颜色
connectingGlobe"正在建立连接..."默认
thinkingBrain"AI 思考中" + 思考内容 + 步骤计数warning(橙)
tool-call (running)Loader2旋转"执行工具中" + 工具名info(蓝)
tool-call (completed)Check"工具执行完成"success(绿)
tool-call (error)X"工具执行失败"error(红)
streamingSparkles"生成回复中"默认

这些状态数据来自 SSE 事件流:后端通过 SseEventSender 发送 think-start/think-content/think-end/tool-call/tool-result 等事件,前端 chat-adapter.ts 解析后通过 onChunk 回调传递给组件。所有卡片使用 slideIn 入场动画。


七、对话记忆与多轮对话

Q26: 对话记忆是怎么持久化的?token预算截断的策略是什么?

答: DatabaseChatMemory 实现了 Spring AI 的 ChatMemory 接口,使用 MyBatis-Plus 将每条消息存入 PostgreSQL 的 chat_message 表(字段:id、conversation_id、message_type、content、metadata JSON、create_time)。

AssistantMessage 的特殊处理MessageConverter.toChatMessage() 会将 tool_calls 列表序列化为 metadata JSON 存储;toMessage() 反序列化时根据 message_type 重建对应的 Message 对象,并从 metadata 中恢复 tool_calls。

Token预算截断get() 方法):

  1. 从DB查最多100条消息(按创建时间倒序)

  2. 反转为时间正序

  3. 调用 TokenEstimator.truncateToTokenBudget() 从最旧消息开始逐条丢弃,直到总token在12000预算内

  4. 始终保留最新一条消息

这保证了多轮对话中,较新的上下文优先保留,较早的对话自然遗忘——符合人类对话的遗忘曲线。


Q27: Agent内部的messageList和数据库的chat_message有什么关系?

答: 两者是独立的:

messageList(Agent内存):Agent单次执行过程中的消息上下文,包含系统提示词、用户消息、LLM回复、工具调用和工具结果。token预算14000。Agent执行结束或重置时清空。

chat_message(数据库):跨会话的对话历史持久化。每次Agent执行完成后,将本轮的用户消息和最终回复(不是中间的think/tool过程)存入数据库。token预算12000。

也就是说:messageList 是"工作记忆"(Agent思考过程中用),chat_message 是"长期记忆"(跨会话保持)。新会话开始时,从 chat_message 加载历史到 messageList,然后追加当前轮的消息。


八、工艺路线链表更新

Q28: 演讲稿说工艺用"链表结构",但代码中工艺路线的工序顺序实际上用的是什么?

答: 需要区分两个层面:

前端画布的数据模型ProcessEntityView.vue 中的 nodes 数组是按拖入顺序排列的,renderLinks() 按数组索引顺序连接相邻节点。这是一个隐式的有序列表

后端工艺路线的数据模型:后端 PlanService.bindPlanProceduresInternal() 方法使用先删后建的全量覆盖策略——先 deleteByCondition 删除该方案的所有工序关联,再 batchCreate 按新的 pprSequence 序号重建。工序顺序由 pprSequence 字段维护,查询时按 pprSequence 升序排列。

演讲稿中提到的"链表"概念,是指逻辑设计思想——将工艺的顺序从固定序号拓展为"下个工序ID"的链式结构,支持分支和合并。后端在数据模型层面通过关系表和序号字段实现了这一思想。全量覆盖更新策略避免了增量更新可能导致的链表断裂或环路问题。


Q29: 全量覆盖更新有什么风险?如何保证数据安全?

答: 全量覆盖的风险是:如果更新过程中出现网络中断或并发冲突,可能导致旧数据已删除但新数据未完全写入。

我们的缓解措施:

  1. 操作在同一个请求中完成bindPlanProceduresInternal() 先删除再批量创建,两者在同一个方法调用中,由 xDM-F 事务保证原子性

  2. Preview-Confirm 流程:工艺路线的更新工具走预览-确认流程,用户确认前不会执行实际的删除和重建

  3. 双键对齐:通过 XdmfDualKeyMapper.align() 确保每个工序的 ID 和 Code 都被正确解析,不会因为 ID/Code 不匹配导致关联错误


九、综合性问题

Q30: 如果给你更多时间,你会优先改进哪些技术点?

答: 按优先级排序:

  1. 并发安全:操作令牌存在内存 ConcurrentHashMap 中,分布式部署会丢失。应改为 Redis 存储,并增加分布式锁

  2. Agent可观测性:当前只有结构化日志,缺乏 metrics(工具调用延迟、意图识别准确率、token消耗分布)。应接入 Prometheus/Grafana

  3. 画布的分支/合并可视化:后端数据模型已支持链表结构的分支合并,但前端画布目前只实现了线性连接。需要实现自定义连线(从节点任意端口拖出)和连线删除

  4. 知识库增量更新:当前上传文档是全量入库,应支持文档版本管理和增量更新(删除旧块、只重新入库变更的块)

  5. 流式思考的token成本thinkStreamReal() 使用 CountDownLatch 等待60秒,如果LLM响应慢会阻塞线程。应改为响应式处理(Flux 直接订阅)

评论

快捷导航

把好文章收藏到微信

打开微信,扫码查看

关闭

还没有账号?立即注册