Files
WeChat-AI/docs/runtime-settings.md

7.5 KiB
Raw Permalink Blame History

运行时配置(管理面板)

/admin设置 页可以改绝大多数原本只能写在 .env 里的配置。

优先级

.env(进程启动时读一次)  ←  默认值
        ↓ 被覆盖
wa:settings:runtimeRedis JSON  ←  管理面板写入
        ↓
生效配置(进程内的同一个 cfg 对象)
  • Redis 里没有某一项时,就用 .env 的值。
  • 面板里把某项改回 .env 默认值,会删除该覆盖 —— 以后再改 .env 又能生效。
  • 「全部恢复默认」删除整份 Redis 覆盖文档。

传播

每个节点每 5 秒 读一次 wa:settings:runtime,有变化才写入本进程配置并推给各服务。 所以:改动在本节点立即生效,其他节点最多 5 秒。没有用 pub/sub —— 一次 GET 相对请求路径上的 Redis 流量可以忽略,也省掉一条订阅连接。

范围

不可配置(env-only,因为它们要么在能读 Redis 之前就得正确,要么改了会破坏已有数据:

变量 原因
REDIS_URL 鸡生蛋:覆盖本身存在 Redis 里
LLM_BASE_URL / LLM_API_KEY / LLM_MODEL 平台模型凭证
LLM_PROVIDER_SECRET 改了之后所有用户已加密的自定义模型 key 全部解不开
WECHAT_AI_TOKEN / LINUXDO_ADMIN_IDS 权限根,改错会把自己锁在外面
SESSION_COOKIE_NAME / COOKIE_SECURE 改了当场踢掉所有会话
PUBLIC_BASE_URL / CORS_ORIGINS 站点自身 URL 与跨域白名单
WECHAT_AI_HOST / WECHAT_AI_PORT 监听端口在 listen() 时固定

其余全部在面板里,包括 TOOLS_BASE_URL / TOOLS_API_KEY(密钥在 UI 里只显示掩码)。

apps/api/src/runtime-config.test.ts 有一条守卫用例:任何新增的 AppConfig 字段要么进 SETTING_SPECS,要么进那份 env-only 名单,否则测试直接失败。

DEFAULT_PERSONA_SLUG 刻意不在面板里:cfg.defaultPersonaSlug 在代码里没有任何消费方, 默认人设走的是数据库 is_default(管理台「人设」→ 设默认)。放进面板只会得到一个 「保存成功但什么都没发生」的控件。

需重启的项

绝大多数配置是热生效的。以下几项写入 Redis 后会打上橙色「需重启」徽章:

为什么
WORKER_ENABLED worker.start() 只在启动时跑一次
REPLY_CONCURRENCY 消费者池在 start() 一次性拉起,没有单消费者取消机制,缩容做不到
LOG_LEVEL Fastify 创建实例时固化 logger
STICKER_MAX_BYTES 路由注册时固化 bodyLimit

热生效是怎么做到的

cfg 对象按引用传给 registerRoutes 和各个服务,所以:

  • 路由87 处 ctx.cfg.* 是每请求读的,原地改 cfg 就够了,零改动。
  • 在构造时快照选项的服务:由 runtime-config-apply.ts 显式推送 —— ChatService / TryChatService / BotWorkerManager / ActivityBus 各有一个 applyRuntimeOptions(或 applyRuntimeConfig)。

几个需要特殊处理、否则会静默失效的地方:

  • ChatService.webSearch 原本在构造函数里判断一次,WEB_SEARCH_ENABLED=false 启动就永远 是 null。现在按 tools 配置的指纹惰性重建。
  • BotWorkerManager 的 10 个 readonly 标量改成可写,且 setter 里复刻了构造函数的 clamp —— 面板不能写进一个构造函数本来会拒绝的值(比如 leaseRenewSec=0)。
  • leaseRenewSec 还决定 setInterval 周期,改动时重新装载定时器。
  • ProactiveScheduler.globalEnabledstart() 闩住(早退且留下 stopped=true), false→true 必须重新进 start(),光改标志没用。
  • setRedisCommandHook 现在无条件安装,否则 DATA_STREAM_ENABLED 这个开关永远是死的。

API

整个页面(含只读)限超管。 超管 = 仍是管理员的用户里 created_at 最早那位。

Method Path 权限 说明
GET /api/v1/admin/settings/runtime 超管 分组 + 全部项(当前值 / env 默认值 / 是否已覆盖 / 是否需重启)+ 交叉校验警告
PATCH /api/v1/admin/settings/runtime 超管 { patch: {key: value}, reset: [key] }
POST /api/v1/admin/settings/runtime/reset 超管 删除全部覆盖

连读也限超管:这个面板能拿到 tools 网关密钥,能整个集群关掉 worker, payload 本身也把每一个调参项和当前生效值列了出来。普通管理员访问返回 403 super_admin_required

前端跟着服务端一起收口,和「节点 / 广播 / 数据流」用同一套三处门禁: switchTab 拦截并回落到「系统」页、boot() 隐藏侧栏按钮与 <section>、 命令面板(Ctrl/⌘K)过滤掉该条目。

两个写操作都会记审计(runtime_settings_updated / runtime_settings_reset), 含变更的 key 列表与需重启的 key。

密钥字段(type: "secret")的约定:

  • GET 只返回掩码 ••••••••,永远不回真值
  • PATCH 传空字符串 = 不修改
  • PATCH 传 - = 清空
  • 把掩码原样回传会被忽略,不会变成字面值

交叉校验

保存时会返回警告(只提示,不改用户填的值):租约 TTL ≤ 续约间隔、延迟上下限倒挂、 memoryFullInjectMax < memoryTopK、开了联网但没配 tools 网关、配了网关但密钥为空、 二次过滤与模型直出 JSON 同时开启。

Redis 读失败时的行为

「读不到」和「不存在」必须分开——把两者都当成「没有覆盖」会让一次网络抖动 把整个节点悄悄退回 .env(此前被面板收紧的开关会瞬间放开)。所以:

  • 轮询读失败 → 保持上一份已知配置不动,打一条日志,refresh() 返回 false。
  • 写路径读失败 → 直接拒绝,返回 503。绝不能基于失败的读做合并再写回, 那会把其他所有覆盖一起抹掉。
  • 例外:全部恢复默认。它本来就要丢弃旧文档,所以读不到时也允许执行 —— 这是文档损坏时唯一的产品内自救路径。

取值受限的项

SettingSpec.options 声明闭集,面板渲染成下拉框,服务端对不在集合里的值直接拒绝 (不做替换,免得把拼写错误藏起来)。目前只有 LOG_LEVEL 用到:pino 遇到未知级别会在 Fastify 建实例时抛错,而这一项又是「需重启」,一个拼写错误会在下次重启时让每个节点 反复崩溃,而且管理接口起不来、改不回去。

并发写

patch() 是读-改-写:读 Redis 当前文档 → 合并 → 整份写回。合并是按文档而不是按字段的, 所以两个超管在两个节点同时保存,后写的那份会带着自己的改动覆盖掉前一份的全部改动。

为此写路径上加了一把短锁 wa:settings:runtime:lockSET NX EX 5,最多重试 5 次 × 120ms),把整个集群的读-改-写串起来。锁只在写时用,读路径完全不碰它。 拿不到锁时不阻塞管理员,仍然继续写 —— 锁是降低窗口,不是强一致保证;写操作是超管专属 且极低频,这个取舍是刻意的。

多节点注意

MAX_BOTS_PER_WORKERLEASE_TTL_SECREBALANCE_SLACK 参与的是集群共享的租约/再平衡 协议。因为覆盖存在 Redis、所有节点读同一份,各节点最终看到的是同一组值 —— 但在 5 秒收敛 窗口内可能短暂不一致,表现为一次多余的再平衡。改这几项建议避开高峰。