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字符串写入。
查询时,采用三级降级策略获取设备数据:
优先:通过
Procedure_Equipment_Rel/queryRelationship查物理关联关系降级:解析
productionEquipment/inspectionEquipmentJSON字段提取ID列表兜底:解析
equipmentJson字段提取设备编码和ID
最后用 categorizeEquipments() 方法将设备列表按ID匹配分为生产设备和检验设备返回给前端。
列表页使用 WorkingProcedureQueryViewDTO,只包含基础字段(编码、名称、工时等),不加载设备数据,避免了N+1查询。
Q2: 为什么列表页不加载设备数据?前端如何处理?
答: 列表页的 WorkingProcedureQueryViewDTO 只映射了 id、procedureCode、name、defaultDuration、operator、productionSteps、procedureDesc 这些基础字段。如果列表页要加载每个工序的关联设备,就需要对每条记录都查询 Procedure_Equipment_Rel 关系表,产生N+1查询问题。
我们的做法是:列表页只展示基础信息,用户点击某个工序进入详情页时,才触发一次完整的设备查询(三级降级策略)。这样列表页的API响应时间是O(1)级别的,而不是O(N)。
Q3: 设备JSON字段更新时如何保持一致性?
答: 在 ProcedureService.update() 方法中(第875-885行),更新前会先从xDM-F获取现有实体,检查 equipmentJson 字段是否存在。如果请求中没有携带该字段,则将原有的 equipmentJson 保留到更新参数中,避免被覆盖。
设备关联关系的变更通过 maintainProcedureEquipmentRels() 方法处理,在关系维护完成后,调用 updateProcedureEquipmentJsonField() 方法重新收集所有关联设备ID,序列化为JSON字符串后回写到对应的分类字段(productionEquipment 或 inspectionEquipment)。
也就是说:关系表是主数据,JSON字段是从属冗余,每次关系变更后自动同步。
二、AI Agent 架构细节
Q4: Agent的类继承链具体是怎么设计的?每层的抽象边界在哪?
答: 继承链是 BaseAgent → ReActAgent → ToolCallAgent → MiniappAgent:
BaseAgent(基础设施层):
定义状态机:
IDLE → RUNNING → FINISHED/ERROR,支持自动重置提供
run()同步执行和runStream()异步流式执行(CompletableFuture.runAsync+SseEmitter,5分钟超时)内置
messageList消息上下文,通过truncateMessageList()基于 token 预算(14000)自动截断最旧消息runStream()包含 SSE 连接断开检测(捕获SseSendException),断开后立即终止循环
ReActAgent(范式层):
抽象类,定义
think()返回 boolean(true=需要行动,false=思考完成)、act()返回 Stringstep()方法实现"先想后做":调用think(),若返回 false 则 FINISHED,否则调用act()
ToolCallAgent(实现层):
think()的具体实现:截断消息列表 → 构造 Prompt → 调用 LLM → 对 tool_calls 做去重(ToolCallDedupeUtil)→ 判断是否有 tool_callsthinkStreamReal()流式思考:使用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_index 或 chunk_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 的分块策略是级联回退:
优先按水平分割线(
---或***)分块其次按标题(
#、##等)分块最后按段落(空行)分块
分块后:
低于
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 中。
安全性保证有四层:
不可预测:UUID v4 部分是随机生成的,无法枚举
TTL过期:
ConfirmationContext的DEFAULT_TTL_MS为10分钟,getContext()会自动检查并移除过期条目工具名匹配:
confirm()时会验证 token 对应的toolName与 executor 注册的工具名是否一致,防止用A操作的token去执行B操作一次性消费:
confirm()执行后立即从contextStore中remove,同一个 token 不能重复使用
不足之处是:token 存在内存中,不支持分布式部署。生产环境需要改为 Redis 存储。
Q20: 哪些工具注册了确认流程?确认执行时完全绕过LLM吗?
答: 注册了确认流程的工具包括:PartUpdateTool、PartUpdateStatusTool、PartBomUpdateTool、EquipmentUpdateTool、EquipmentUpdateStatusTool、PartBatchUpdateTool。
确认执行完全绕过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),拒绝状态变更为 STOPPED 或 SCRAPPED。
这个检查在三个地方被调用:
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(浏览器行为)。此时回退到两个备选方案:
使用
document.activeElement的getBoundingClientRect()获取输入框位置使用选区
startContainer.parentElement的位置
定位逻辑:默认显示在选区左侧(rect.left - toolbarWidth - 8),空间不足时切换到右侧,上下边界溢出时裁剪。
复制功能:优先使用 navigator.clipboard.writeText(现代API),不支持时回退到 document.execCommand('copy')(创建临时 textarea 复制后销毁)。
Q25: AiAgentStatusStrip如何实时展示Agent的思考和工具调用过程?
答: AiAgentStatusStrip.vue 根据 agentPhase prop 渲染不同状态卡片,每种状态有独立的图标、文案、颜色方案和动画:
| 阶段 | 图标 | 文案 | 颜色 |
|---|---|---|---|
| connecting | Globe | "正在建立连接..." | 默认 |
| thinking | Brain | "AI 思考中" + 思考内容 + 步骤计数 | warning(橙) |
| tool-call (running) | Loader2旋转 | "执行工具中" + 工具名 | info(蓝) |
| tool-call (completed) | Check | "工具执行完成" | success(绿) |
| tool-call (error) | X | "工具执行失败" | error(红) |
| streaming | Sparkles | "生成回复中" | 默认 |
这些状态数据来自 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() 方法):
从DB查最多100条消息(按创建时间倒序)
反转为时间正序
调用
TokenEstimator.truncateToTokenBudget()从最旧消息开始逐条丢弃,直到总token在12000预算内始终保留最新一条消息
这保证了多轮对话中,较新的上下文优先保留,较早的对话自然遗忘——符合人类对话的遗忘曲线。
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: 全量覆盖更新有什么风险?如何保证数据安全?
答: 全量覆盖的风险是:如果更新过程中出现网络中断或并发冲突,可能导致旧数据已删除但新数据未完全写入。
我们的缓解措施:
操作在同一个请求中完成:
bindPlanProceduresInternal()先删除再批量创建,两者在同一个方法调用中,由 xDM-F 事务保证原子性Preview-Confirm 流程:工艺路线的更新工具走预览-确认流程,用户确认前不会执行实际的删除和重建
双键对齐:通过
XdmfDualKeyMapper.align()确保每个工序的 ID 和 Code 都被正确解析,不会因为 ID/Code 不匹配导致关联错误
九、综合性问题
Q30: 如果给你更多时间,你会优先改进哪些技术点?
答: 按优先级排序:
并发安全:操作令牌存在内存
ConcurrentHashMap中,分布式部署会丢失。应改为 Redis 存储,并增加分布式锁Agent可观测性:当前只有结构化日志,缺乏 metrics(工具调用延迟、意图识别准确率、token消耗分布)。应接入 Prometheus/Grafana
画布的分支/合并可视化:后端数据模型已支持链表结构的分支合并,但前端画布目前只实现了线性连接。需要实现自定义连线(从节点任意端口拖出)和连线删除
知识库增量更新:当前上传文档是全量入库,应支持文档版本管理和增量更新(删除旧块、只重新入库变更的块)
流式思考的token成本:
thinkStreamReal()使用CountDownLatch等待60秒,如果LLM响应慢会阻塞线程。应改为响应式处理(Flux直接订阅)


评论