docs(server): ai context

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
committed by RainbowBird
parent c4018c63c3
commit c0d4c9043a
8 changed files with 558 additions and 19 deletions
+27
View File
@@ -0,0 +1,27 @@
# `@proj-airi/server`
HTTP and WebSocket backend for AIRI. This app owns auth, billing, chat synchronization, gateway forwarding, and server-side observability export.
## What It Does
- Serves the Hono-based API and WebSocket endpoints.
- Uses Postgres as the source of truth for users, billing, and durable state.
- Uses Redis for cache, KV, Pub/Sub, and Streams.
- Forwards GenAI requests to the configured upstream gateway and records billing from usage.
- Exports traces, metrics, and logs through OpenTelemetry.
## How To Use It
Install dependencies from the repo root and run scoped commands:
```sh
pnpm -F @proj-airi/server typecheck
pnpm -F @proj-airi/server exec vitest run
pnpm -F @proj-airi/server build
```
For local observability infrastructure, use:
```sh
docker compose -f apps/server/docker-compose.otel.yml up -d
```
+9
View File
@@ -17,8 +17,14 @@
- 主要表、状态归属、缓存与事件模型
- `workers-and-runtime.md`
- CLI 角色、outbox dispatcher、Redis Streams consumer、运行时约束
- `redis-boundaries-and-pubsub.md`
- Redis key / channel 收口、Pub/Sub / Streams 边界、运行时校验约束
- `config-and-naming-conventions.md`
- `configKV` 默认值来源、Redis key 命名、HTTP route 命名、后续收敛 TODO
- `billing-architecture.md`
- 计费链路专项说明,重点看 Flux / Stripe / outbox / Redis Streams
- `observability-conventions.md`
- traces / metrics 命名规则,标准 OTel 字段与 `airi.*` 自定义字段边界
## 快速结论
@@ -35,4 +41,7 @@
- 改某个接口行为:先看 `transport-and-routes.md`
- 改表结构、缓存或幂等:先看 `data-model-and-state.md`
- 改 worker、部署角色、事件处理:先看 `workers-and-runtime.md`
- 改 Redis key、Pub/Sub、Streams 边界:先看 `redis-boundaries-and-pubsub.md`
- 改配置默认值、Redis key 命名、HTTP route 命名:先看 `config-and-naming-conventions.md`
- 改扣费、充值、Stripe:先看 `billing-architecture.md`
- 改 trace / metric attributes、OTel 命名:先看 `observability-conventions.md`
@@ -0,0 +1,161 @@
# Config And Naming Conventions
## 目标
这篇文档收口三类容易逐步漂移的约定:
- `configKV` 的默认值和读取语义
- Redis key / channel 的命名规则
- HTTP route 的资源命名规则
这些约定不是“代码风格建议”,而是为了减少:
- 默认值写两份导致的配置漂移
- Redis key 命名混用导致的排障成本
- HTTP route 语义不稳定导致的版本化困难
## `configKV` 约定
### 单一真相源
`src/services/config-kv.ts` 中的 `ConfigEntrySchemas` 是以下三件事的单一真相源:
- 配置值的运行时校验
- 配置值的默认值
- Redis 中的序列化 / 反序列化 shape
这意味着:
- 默认值必须定义在 `ConfigEntrySchemas`
- 业务代码不要再写第二份 `?? defaultValue`
- `configKV.get()` / `configKV.getOrThrow()` 应直接依赖 schema 默认值
### 读取语义
- `getOptional(key)`
- 用于“这个 key 合法地可以不存在”的场景
- 对 required key,未配置时返回 `null`
- 对带 schema 默认值的 key,返回默认值
- `getOrThrow(key)`
- 用于“缺失就是配置错误”的场景
- required key 未配置时抛 `CONFIG_NOT_SET`
- `get(key)`
-`getOrThrow(key)` 的别名
- 默认用于业务代码
### 禁止事项
- 不要给 `getOptional` 增加调用点默认值参数,例如 `getOptional(key, fallback)`
- 不要同时在 schema 和调用侧维护两份默认值
- 不要绕过 `configKV` 直接从 Redis 读配置
### 当前例子
推荐:
```ts
const fluxPer1kTokens = await configKV.get('FLUX_PER_1K_TOKENS')
const maxCheckoutAmount = await configKV.get('MAX_CHECKOUT_AMOUNT_CENTS')
```
不推荐:
```ts
const fluxPer1kTokens = (await configKV.getOptional('FLUX_PER_1K_TOKENS')) ?? 1
const maxCheckoutAmount = (await configKV.getOptional('MAX_CHECKOUT_AMOUNT_CENTS')) ?? 1_000_000
```
## Redis key / channel 命名
### 命名规则
Redis key 和 channel 统一采用“分段命名”,推荐使用冒号 `:` 作为分隔符:
```txt
{scope}:{id}:{resource}
{scope}:{id}:{subscope}:{subid}:{resource}
lock:{domain}:{id}
config:{key}
```
推荐例子:
```txt
user:{userId}:flux
config:{key}
chat:{userId}:broadcast
lock:user:{userId}:flux
```
不推荐例子:
```txt
flux:{userId}
chat:broadcast:{userId}
userFlux:{userId}
userUidFlux1
```
### 设计原则
- 前缀表达 namespace,而不是随手缩写
- 真实 Redis key 不要出现 `1``2` 这种占位编号
- 参数占位编号只用于文档里的模板名,不用于运行时 key
- key / channel 必须通过 helper 收口,不要在业务代码里散落模板字符串
### 文档里的模板命名
如果要在文档中表示“这个 key 有几个参数位”,可以用编号描述模板:
- `userUserId1Flux`
- `configKey1`
- `lockDomain1Id1`
但最终真实 key 仍然必须是:
```txt
user:{userId}:flux
config:{key}
lock:{domain}:{id}
```
## HTTP route 命名
### 资源命名原则
- 优先使用复数资源名
- 从属资源优先挂在父资源下
- 当前登录用户资源优先使用 `me`
推荐:
```txt
/api/users/me/flux
/api/users/me/flux/history
```
不推荐:
```txt
/api/user/flux
/api/flux
```
### 版本化约束
如果某类 HTTP API 需要长期稳定对外契约,优先在一个明确子树下版本化,例如:
```txt
/api/v1/...
/api/openai/v1/...
```
不要让“部分资源版本化、部分资源裸挂”长期并存而没有说明。
## TODO
-`apps/server` 中现有 Redis key / channel 继续向 helper 收口,避免业务代码里散落模板字符串。
- 统一把旧式 key 命名迁移到分段命名风格,优先处理 Flux cache、chat broadcast、lock key。
-`configKV` 增补一份“哪些配置属于 infra、哪些属于运营策略”的清单,避免继续模糊放置位置。
- 把所有 `configKV.getOptional(...) ?? defaultValue` 模式清理掉,默认值统一回到 `ConfigEntrySchemas`
- 评估是否把 `/api/v1` 收口为兼容层 API,并明确业务资源是否也需要统一版本化。
@@ -0,0 +1,159 @@
# Observability Conventions
这份约定定义 AIRI 服务端新增 trace / metric attributes 时应该遵守的命名规则,目标是减少自定义前缀扩散,并让 Grafana / Tempo / Loki 查询尽量对齐 OpenTelemetry 语义约定。
## 总原则
- 能直接映射到 OpenTelemetry semantic conventions 的字段,优先使用标准字段。
- 不能映射到标准字段、但确实属于 AIRI 业务语义的字段,统一放到 `airi.*` 命名空间下。
- 不要新增新的顶级前缀,例如 `llm.*``gateway.*``telegram.*` 之类的 attribute key。
- span name、event name、metric name 不等于 attribute key;是否迁移它们要单独评估兼容性。
- 代码里不要继续散落新的 observability key 字符串字面量;统一从 [packages/server-shared/src/observability.ts](/Users/luoling8192/Git/moeru-ai/airi/packages/server-shared/src/observability.ts) 引用。
## 标准字段优先级
### GenAI
优先使用:
- `GEN_AI_ATTR_OPERATION_NAME`
- `GEN_AI_ATTR_REQUEST_MODEL`
- `GEN_AI_ATTR_USAGE_INPUT_TOKENS`
- `GEN_AI_ATTR_USAGE_OUTPUT_TOKENS`
- `SERVER_ATTR_ADDRESS`
- `SERVER_ATTR_PORT`
适用场景:
- chat completion
- embeddings
- 其他能明确归类到 GenAI 上游调用的请求
注意:
- 当前 OpenTelemetry GenAI semantic conventions 仍处于 `Development` 状态,因此只在“语义明确匹配”时采用。
- 没有明确标准归属的字段不要硬塞进 `gen_ai.*`
### Database / Redis
优先使用:
- `db.system.name`
- `db.operation.name`
- `db.namespace`
- `db.query.text`
- `db.response.status_code`
- `server.address`
- `server.port`
Redis 相关优先复用 instrumentation 自动产生的标准属性,不要重复造一套并行命名。
## AIRI 自定义字段
以下场景使用 `airi.*`
- 计费或余额语义
- 仅 AIRI 内部存在的流式控制字段
- 临时调试但仍需要进入可观测系统的业务字段
当前示例:
- `AIRI_ATTR_BILLING_FLUX_CONSUMED`
- `AIRI_ATTR_GEN_AI_STREAM`
- `AIRI_ATTR_GEN_AI_STREAM_INTERRUPTED`
- `AIRI_ATTR_GEN_AI_OPERATION_KIND`
- `AIRI_ATTR_GEN_AI_INPUT_MESSAGES`
- `AIRI_ATTR_GEN_AI_INPUT_TEXT`
- `AIRI_ATTR_GEN_AI_OUTPUT_TEXT`
## Metric Name 策略
当前 `apps/server` 仍保留以下 metric name
- `llm.request.duration`
- `llm.request.count`
- `llm.tokens.prompt`
- `llm.tokens.completion`
- `flux.consumed`
这是有意为之,不是遗漏。
原因:
- metric name 改动比 attribute 改动更容易破坏现有 Prometheus 查询、Grafana 面板和告警。
- 目前更高价值的是先统一 metric attributes,使查询维度稳定。
- 如需迁移 metric name,应该走兼容迁移方案,而不是在普通功能改动里直接重命名。
## Grafana / Prometheus 查询策略
面板和告警查询优先依赖 metric labels,对齐我们已经统一的 attributes。
### GenAI 面板应该查什么
优先使用这些 Prometheus label
- `gen_ai_request_model`
- `gen_ai_operation_name`
- `airi_gen_ai_operation_kind`
- `http_response_status_code`
说明:
- Prometheus 暴露时会把 attribute key 里的 `.` 转成 `_`,所以 `gen_ai.request.model` 会变成 `gen_ai_request_model`
- `gen_ai_operation_name` 适合 chat、embeddings 这类有明确 semconv 的操作。
- `airi_gen_ai_operation_kind` 适合当前没有明确 semconv 的 AIRI 自定义操作类型,例如 `tts``asr`
### 不再新增使用的旧查询维度
新增 dashboard、录制规则、告警时,不要再新增依赖这些旧 label:
- `model`
- `type`
旧面板可以渐进迁移,不要求一次性全部替换,但新改动必须直接使用新标签。
### 当前已落地的 dashboard 例子
[apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json](/Users/luoling8192/Git/moeru-ai/airi/apps/server/otel/grafana/dashboards/airi-server-overview-cloud.json) 已经按以下方式查询:
- Request rate by model: `gen_ai_request_model`
- Request rate by operation: `gen_ai_operation_name` + `airi_gen_ai_operation_kind`
- Latency by model: `gen_ai_request_model`
- Flux consumed by model: `gen_ai_request_model`
- Token throughput by model: `gen_ai_request_model`
如果未来新增本地 dashboard 或新的 cloud dashboard,默认按这一套 label 维度来。
## Span Name 策略
span name 目前允许保留业务可读格式,例如:
- `llm.gateway.chat`
- `llm.gateway.tts`
- `llm.gateway.asr`
原因:
- span name 主要服务于人工浏览和局部检索。
- 语义筛选应优先依赖 attributes,而不是依赖 span name 文本。
如果未来统一 span name,也应保证查询主要依赖 `gen_ai.*` / `db.*` / `airi.*` attributes。
## 修改前检查
新增 observability 字段前,先问自己:
1. 这个字段能否映射到已有 OTel semconv
2. 如果不能,它是否明确属于 AIRI 业务语义?
3. 如果属于 AIRI,是否应该挂到 `airi.*`,而不是新造顶级前缀?
4. 我改的是 attribute key 还是 metric name / span name
5. 如果是 metric name,是否已经评估 Prometheus / Grafana / alerting 兼容性?
6. 如果要改 dashboard,我是否优先用了 `gen_ai_request_model``gen_ai_operation_name``airi_gen_ai_operation_kind`,而不是旧的 `model` / `type`
## 当前参考实现
- [packages/server-shared/src/observability.ts](/Users/luoling8192/Git/moeru-ai/airi/packages/server-shared/src/observability.ts)
- [apps/server/src/routes/v1completions.ts](/Users/luoling8192/Git/moeru-ai/airi/apps/server/src/routes/v1completions.ts)
- [apps/server/src/libs/otel.ts](/Users/luoling8192/Git/moeru-ai/airi/apps/server/src/libs/otel.ts)
- [services/telegram-bot/src/llm/actions.ts](/Users/luoling8192/Git/moeru-ai/airi/services/telegram-bot/src/llm/actions.ts)
- [services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts](/Users/luoling8192/Git/moeru-ai/airi/services/telegram-bot/src/bots/telegram/agent/actions/read-message.ts)
@@ -0,0 +1,188 @@
# Redis Boundaries And Pub/Sub
## 目标
这篇文档约束服务端使用 Redis 时的几个高风险边界:
- key / channel 拼接
- Pub/Sub payload 序列化与反序列化
- Redis 返回值的运行时校验
- Redis 与 Postgres 的职责边界
这不是“推荐写法”集合,而是后续改代码时应该默认遵守的约束。
## 一句话规则
- 不要在业务代码里到处手写 Redis key / channel 模板字符串。
- 不要把 TypeScript 类型注解当成 Redis 边界的运行时校验。
- 不要把 Pub/Sub 当持久化通道。
- 不要让 Redis 承担余额、账本、订单这类真相源职责。
## Redis 职责边界
当前 `apps/server` 中 Redis 主要承担四类职责:
- cache
- 例如 `user:{userId}:flux`
- config KV
- 例如 `config:{key}`
- Pub/Sub
- 例如聊天跨实例广播 `chat:{userId}:broadcast`
- Streams
- 例如 `billing-events`
其中:
- Postgres 是余额、账本、订单、聊天消息等持久状态的唯一真相源
- Redis Pub/Sub 只负责降低跨实例通知延迟,不提供持久化、回放、补偿
- Redis Streams 用于异步事件消费,但也必须在边界层做输入输出校验
## Key / Channel 收口规则
### 必须收口
Redis key 和 Pub/Sub channel 必须通过单独 helper 构造,不要在多个调用点重复写模板字符串。
推荐模式:
```ts
function fluxRedisKey(userId: string): string {
return `user:${userId}:flux`
}
function userBroadcastChannel(userId: string): string {
if (typeof userId !== 'string' || userId.length === 0) {
throw new TypeError('user broadcast channel requires a non-empty string userId')
}
return `chat:${userId}:broadcast`
}
```
这样做的原因不是“风格统一”,而是为了避免:
- key 前缀分散在多个文件
- 某个调用点把对象、空串、错误 id 拼进 channel
- 后续重构前缀或路由粒度时漏改
### 禁止依赖模板字符串兜底
不要假设 `` `${value}` `` 可以安全把任意值转成 Redis key。
原因:
- 如果 `value` 在运行时是对象,会得到 `[object Object]`
- 这类错误不会在 TypeScript 编译期暴露
- 一旦写进 Redis channel / key,排查成本很高
## Pub/Sub Payload 规则
### 发布侧
发布侧必须显式构造消息对象,不要把“业务对象刚好长得像 payload”当成协议。
推荐模式:
```ts
interface BroadcastMessage {
userId: string
payload: {
chatId: string
messages: unknown[]
fromSeq: number
toSeq: number
}
}
function createBroadcastMessage(
userId: string,
payload: BroadcastMessage['payload'],
): BroadcastMessage {
if (typeof userId !== 'string' || userId.length === 0) {
throw new TypeError('broadcast message requires a non-empty string userId')
}
return { userId, payload }
}
```
### 消费侧
消费侧不要只写:
```ts
const data = JSON.parse(message) as BroadcastMessage
```
因为这只是类型断言,不是校验。
至少要验证:
- `userId` 是非空字符串
- `payload.chatId` 是字符串
- `payload.messages` 是数组
- `fromSeq` / `toSeq` 是数字
如果消息不合法,应该记录错误并丢弃,而不是继续广播到本地连接。
## Streams 边界规则
Redis Streams 的参考实现已经在 `src/libs/mq/stream.ts` 里。
这层模式值得复用的点有两个:
- 输入通过 `serialize()` 收口
- 输出通过 `deserialize()` 和运行时检查收口
也就是说:
- 不要在业务代码里裸写 `XADD` / `XREADGROUP`
- 不要相信 Redis 返回值一定符合你期望的 shape
- 边界校验失败时应该尽早抛错,而不是继续传播脏数据
## Chat WS 当前约束
`src/routes/chat-ws.ts` 当前采用:
- 同实例内存连接表
- 跨实例 Redis Pub/Sub
这个设计的语义必须明确:
- 广播通知不是持久化消息
- 丢广播不会丢聊天真相数据
- 客户端补齐消息仍然依赖 `pullMessages`
因此后续如果改聊天同步:
- 需要“可重放”时,不要继续堆在 Pub/Sub 上
- 需要“跨实例即时通知”时,可以继续用 Pub/Sub
- 需要“持久事件消费”时,应优先考虑 Streams
## 修改 Redis 代码时的检查清单
- 这个 Redis 数据是 cache、KV、Pub/Sub 还是 Stream
- 它是不是被误当成真相源?
- key / channel 是否通过 helper 统一构造?
- 是否校验了关键标识符,例如 `userId`、`chatId`、`streamMessageId`
- Pub/Sub payload 是否有显式创建函数和解析函数?
- 解析失败时是否会安全丢弃,而不是继续传播?
- 这个需求是否其实应该用 Streams,而不是 Pub/Sub
## 当前代码可直接参考的位置
- key helper
- `src/services/flux.ts`
- Streams 边界封装
- `src/libs/mq/stream.ts`
- Pub/Sub 聊天广播
- `src/routes/chat-ws.ts`
- 命名规范和待迁移事项
- `config-and-naming-conventions.md`
## 对 AI / 后续修改者的直接要求
- 新增 Redis key / channel 时,先写 helper,再写调用点
- 新增 Pub/Sub payload 时,先定义消息 shape 和 parse / create 边界,再接业务逻辑
- 如果看到业务代码里散落 `` `prefix:${id}` ``,优先做小范围收口
- 如果看到 `JSON.parse(...) as SomeType` 出现在 Redis 边界,默认把它视为待修复点
@@ -10,7 +10,7 @@
- `/api/providers`
- `/api/chats`
- `/api/v1`
- `/api/flux`
- `/api/users/me/flux`
- `/api/stripe`
- `GET /ws/chat`
@@ -135,6 +135,12 @@
- 同实例:内存 `Map<userId, Set<EventContext>>`
- 跨实例:Redis Pub/Subchannel 前缀 `chat:broadcast:`
实现约束:
- Redis Pub/Sub 只承担通知职责,不承担持久化和重放职责
- key / channel 与 payload 边界应集中收口,不要在调用点散落模板字符串和裸 `JSON.parse`
- 具体规范见 `redis-boundaries-and-pubsub.md`
### `/api/v1`
实现位置:
@@ -150,11 +156,8 @@
- `POST /api/v1/chat/completions`
- `POST /api/v1/chat/completion`
已实现但暂未挂载:
- `handleTTS`
- `handleTranscription`
- `POST /api/v1/audio/speech`
- `POST /api/v1/audio/transcriptions`
请求流程:
@@ -178,7 +181,7 @@
- 流结束后再 best-effort 扣费
- 扣费失败只打 error log,不回滚给客户端
### `/api/flux`
### `/api/users/me/flux`
实现位置:
@@ -189,9 +192,9 @@
主要能力:
- `GET /api/flux`
- `GET /api/users/me/flux`
- 读取当前用户余额
- `GET /api/flux/history`
- `GET /api/users/me/flux/history`
- 读取用户可见流水
### `/api/stripe`
@@ -85,6 +85,8 @@
- 真正补齐消息还是靠 `pullMessages`
- 广播只是为了降低拉取延迟,不代表存在旧式 `sync` 端点
如果要改 Redis key / channel 构造、Pub/Sub payload 或 Streams 边界,先看 `redis-boundaries-and-pubsub.md`
## OpenTelemetry
初始化在 `src/libs/otel.ts`
-10
View File
@@ -8,17 +8,8 @@
"ESNext"
],
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@proj-airi/server-sdk-shared": [
"../../packages/server-sdk-shared/src/index.ts"
],
"@proj-airi/server-sdk-shared/*": [
"../../packages/server-sdk-shared/src/*"
]
},
"resolveJsonModule": true,
"types": [
"vitest",
@@ -29,7 +20,6 @@
"skipLibCheck": true
},
"include": [
"../../packages/server-sdk-shared/src/**/*.ts",
"src/**/*.ts",
"src/**/*.d.ts"
]