7.5 KiB
运行时配置(管理面板)
/admin → 设置 页可以改绝大多数原本只能写在 .env 里的配置。
优先级
.env(进程启动时读一次) ← 默认值
↓ 被覆盖
wa:settings:runtime(Redis 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.globalEnabled被start()闩住(早退且留下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:lock(SET NX EX 5,最多重试 5 次 ×
120ms),把整个集群的读-改-写串起来。锁只在写时用,读路径完全不碰它。
拿不到锁时不阻塞管理员,仍然继续写 —— 锁是降低窗口,不是强一致保证;写操作是超管专属
且极低频,这个取舍是刻意的。
多节点注意
MAX_BOTS_PER_WORKER、LEASE_TTL_SEC、REBALANCE_SLACK 参与的是集群共享的租约/再平衡
协议。因为覆盖存在 Redis、所有节点读同一份,各节点最终看到的是同一组值 —— 但在 5 秒收敛
窗口内可能短暂不一致,表现为一次多余的再平衡。改这几项建议避开高峰。