数字馆长的讲解入口面对的是同一类难题:页面只需要稳定的讲解结果、出处和推荐,而模型开关、网络超时、缓存命中与限流不能散落在每个页面里。山海万灵把请求集中到ai-service,由 Gateway 在进入模型前完成场景收束、上下文装配、缓存判断、限流和审计,再把结构化结果返回给应用。
图中的本地接口回读展示了两条关键结果:受控讲解场景在模型未启用时返回可识别的降级内容;OPEN_CHAT在进入模型前被范围守卫拦截。这样,临时不可用的模型服务不会把开放式输入带进知识应用的正式内容链路。
先把页面动作翻译成受控场景
客户端提交节点、节点类型和场景,服务端只接受馆长讲解相关的有限枚举。无场景时归一到神兽详情;未知值不尝试“猜一个最接近的模型任务”,而是直接走范围守卫。
private static String normalizeScene(String requestedScene) { if (requestedScene == null || requestedScene.isBlank()) { return "BEAST_DETAIL"; } return switch (requestedScene.trim().toUpperCase(Locale.ROOT)) { case "AI_EXPLAIN", "AI_EXPLANATION", "ARCHIVE", "DETAIL", "BEAST_DETAIL" -> "BEAST_DETAIL"; case "BEAST_STORY", "STORY" -> "BEAST_STORY"; case "BEAST_MAP", "MAP" -> "BEAST_MAP"; case "BEAST_HALL", "HALL" -> "BEAST_HALL"; default -> null; }; }场景收束解决了两个工程问题。第一,Prompt Builder 能依据确定的场景选择上下文和输出约束;第二,未知场景无需触碰 Provider 就能返回SCOPE_GUARD,响应中的safetyStatus=BLOCKED可被界面和审计记录一致识别。
| 页面动作 | Gateway 场景 | 输入边界 | 返回重点 |
|---|---|---|---|
| 神兽详情讲解 | BEAST_DETAIL | 节点 ID、物种类型 | 讲解、出处、关联推荐 |
| 故事页追问 | BEAST_STORY | 已发布故事节点 | 叙事上下文与来源 |
| 地图导览 | BEAST_MAP | 地域与关联节点 | 地域线索与推荐路线 |
| 开放聊天 | 未纳入 | 任意自由输入 | 范围拦截与安全状态 |
缓存键要能解释“为什么这次可以复用”
讲解文本不是只按节点 ID 缓存。场景、Prompt 版本、图谱上下文哈希和模型参数都会改变结果,因此它们共同参与请求指纹和缓存键。图谱改边、升级 Prompt 或调节模型参数时,旧结果自然不再匹配新键。
String contextHash = AiGatewayFingerprint.curatorContextHash(context); String modelOptionsHash = AiGatewayFingerprint.modelOptionsHash( modelProvider.providerCode(), properties.getOllama()); String cacheKey = responseCache.key( scene, nodeId, promptMetadata, contextHash, modelOptionsHash); Optional<CuratorExplanation> cached = responseCache.find(cacheKey); if (cached.isPresent()) { CuratorExplanation result = cached.get().forRequest(requestId, true); audit(requestId, clientKeyHash, scene, nodeId, contextHash, modelOptionsHash, requestHash, result, "SUCCESS", null, startedAt); return result; }缓存命中仍会生成新的请求 ID 并写入审计,避免把“复用了结果”误解成“没有发生业务请求”。缓存只保存通过结构化输出校验的讲解;模型不可用、输出格式异常或场景被拒绝时,不把降级文案写进成功缓存。
| 缓存字段 | 作用 | 变更后的行为 |
|---|---|---|
| 场景与节点 | 区分讲解意图和对象 | 切换故事或地图会重新计算 |
| Prompt 版本 | 锁定提示词语义 | 升级 Prompt 后旧条目失效 |
| 图谱上下文哈希 | 绑定出处和关系 | 节点、来源或推荐变化后重取 |
| 模型参数哈希 | 避免混用不同生成设置 | 调整模型或温度后重取 |
限流在调用 Provider 前执行
限流器以 Redis 为主,计数脚本在窗口内递增并设置过期时间。这样多个服务实例能共享同一窗口;Redis 暂时不可用时,服务降到进程内计数,优先保证接口可以返回受控结果而不是把错误扩散到页面。
if (!rateLimiter.tryAcquire(clientKeyHash)) { CuratorExplanation result = catalogService.fallback( context, requestId, "DEGRADED", false, "RATE_LIMIT", promptMetadata); audit(requestId, clientKeyHash, scene, nodeId, contextHash, modelOptionsHash, requestHash, result, "FALLBACK", "RATE_LIMITED", startedAt); return result; }客户端标识不会以原文写入审计表,而是先转为哈希。限流触发后返回的是带来源和安全状态的本地策展内容,页面无需根据异常栈拼装兜底 UI,也不会把“请求太快”伪装成一段远程生成文本。
模型路由与格式化是一条连续链路
通过限流后,Gateway 才构建 Prompt 并调用已配置的 Provider。模型返回值必须经过 JSON 格式化和内容质量校验;任何一步抛出运行时异常都会回落到馆藏策展内容。应用侧始终拿到同一个CuratorExplanation结构,展示层不需要认识具体模型。
PromptPackage prompt = promptBuilder.build(context, scene); ModelCompletion completion = modelProvider.generate(prompt); CuratorDraft draft = outputFormatter.format(completion.content()); GeneratedAnswer answer = contentAssembler.assemble(draft, context, scene); outputQualityValidator.validate(answer, context); CuratorExplanation result = new CuratorExplanation( requestId, ABILITY_TYPE, answer.title(), answer.content(), true, "AI创作", context.sourceNodes(), context.recommendations(), context.sourceReferences(), context.contextNodes(), context.relations(), "PASS", false, false, modelProvider.providerCode(), prompt.metadata()); responseCache.put(cacheKey, result);这里的取舍是把“可生成”与“可展示”分开。Provider 的可用性只决定是否尝试生成;正文、出处节点、推荐和AI创作标识仍由服务端的结构化对象统一组装。应用不保存模型密钥,也不直接访问模型地址。
响应合同让页面只处理业务状态
Gateway 返回的不是某个模型厂商的原始报文,而是面向数字馆长页面的稳定响应。无论结果来自模型、缓存、范围守卫还是本地策展内容,页面都可以读取同一组字段。标题、正文、出处和推荐负责内容呈现;provider、fallback、cacheHit与safetyStatus负责呈现来源和状态,不需要把网络异常映射成另一套页面模型。
public record CuratorExplainResponse( String requestId, String abilityType, String title, String content, boolean aiGenerated, String label, List<String> sourceNodes, List<SourceReferenceItem> sourceReferences, List<ContextNodeItem> contextNodes, List<RelationItem> relations, List<RecommendationItem> recommendations, String safetyStatus, boolean cacheHit, boolean fallback, String provider, String promptId, String promptVersion, String traceId ) { }这种合同把降级做成可见业务状态,而不是隐藏的失败分支。模型关闭时,fallback=true提醒页面继续显示策展内容;范围拦截时,provider=SCOPE_GUARD与safetyStatus=BLOCKED让入口保持受控;缓存命中时,cacheHit=true可以用于性能观察,但不会改变读者看到的讲解结构。后续替换模型 Provider 时,只要这个合同不变,HarmonyOS 页面、CMS 预览和服务端审计都无需跟着迁移模型专属字段。
审计只保留排障需要的指纹
每次请求都记录请求 ID、场景、节点、Prompt 元数据、上下文哈希、模型参数哈希、缓存命中、处理结果、错误码和耗时。审计不保存原始 Prompt、模型原文、密码或密钥;这使问题定位可以聚焦“哪一种受控请求在哪个边界降级”,而不会把内容安全风险带进日志。
| 事件结果 | 典型触发条件 | 返回给页面 | 审计用途 |
|---|---|---|---|
SUCCESS | 模型输出校验通过或命中缓存 | 讲解、出处、推荐 | 区分首次生成与缓存命中 |
BLOCKED | 场景不在白名单 | 安全状态与受控回退 | 识别越界调用 |
FALLBACK | Provider 未启用、超时或限流 | 本地策展内容 | 判断降级原因和耗时 |
如何回读这条闭环
回归用例覆盖了场景归一化、缓存键变化、Redis 限流、审计持久化、Provider 格式化和 HTTP 控制器。当前本地接口回读中,BEAST_DETAIL在 Provider 关闭时得到provider=FALLBACK与safetyStatus=DEGRADED;OPEN_CHAT得到provider=SCOPE_GUARD与safetyStatus=BLOCKED。两种结果都保留在同一响应结构中,前端可以据此展示明确状态。
把一次讲解串成可定位的事件链
当用户在图鉴页请求讲解时,Gateway 先为请求生成 ID,并对客户端标识做哈希。随后,场景解析决定请求是否进入允许集合;上下文目录提供节点、来源和关系;Prompt 元数据、图谱上下文和模型选项共同形成指纹。缓存命中、限流拒绝、模型生成、格式化失败和本地降级最终都会收束为同一种讲解结果。
这条事件链的关键不是记录更多文本,而是保留能够解释结果的关联信息。例如,缓存条目可以由 Prompt 版本和上下文哈希解释;同一个节点在不同场景得到不同的请求指纹;一次范围拦截没有模型名却有SCOPE_GUARD;一次 Provider 关闭导致的降级可以由错误码和耗时定位。审计表只保存这些结构化信息,避免把读者输入、原始 Prompt 或模型原文扩散到日志系统。
页面侧也因此能保持简单:讲解内容总是和出处、推荐一起出现,状态字段只决定是否展示 AI 创作标识、降级提示或重试入口。用户从地图、展厅或神兽详情进入时不需要知道 Redis、Ollama 或格式化器的存在;这些依赖由 Gateway 隔离,业务页面只处理可阅读的知识结果和明确的安全状态。
生产部署还需要把共享 Redis、可信身份传递和模型容量作为独立的运行条件:Redis 不可用时不能把进程内计数当成多实例限流;显存和模型超时必须按实际并发压测设置;发布内容的出处约束不能因为模型可用而放宽。关于 HarmonyOS 端的 AI 能力边界,可参考 HarmonyOS AI 能力介绍。