@tansr/serve 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +380 -9
- package/dist/index.d.ts +10969 -2496
- package/dist/index.js +29240 -14477
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
# tansr
|
|
1
|
+
# tansr Agent 会话引擎(`@tansr/serve`)
|
|
2
2
|
|
|
3
3
|
> **对外发布名 = `@tansr/serve`(用户拍板 2026-09-01)**。workspace 内恒以
|
|
4
4
|
> `@tansr/server` 引用;发布 tarball 由 `pnpm release:pack-serve` 盖发布名。
|
|
5
5
|
> **license = MIT**(同批拍板;正式协议考察待全部开发完成后进行)。
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
**定名(doc/118 §八 G1,2026-09-03 用户授权代拍)**:本包是 tansr **Agent 会话引擎**
|
|
8
|
+
——多租 Agent 会话的协议引擎(`/v2` REST + SSE)+ **内置真平台装配**(A 案,用户
|
|
9
|
+
拍板 2026-09-01),以 npm 包形态嵌进**你自己的 Node 服务**。**本包不是网关**:
|
|
10
|
+
「网关」一词在 tansr 体系内只指 tansr-api,鉴权策略/限流/配额/计费归开发者的登录态
|
|
11
|
+
体系与平台侧;引擎自带的准入帽、`rateLimit` 缝与上游治理层是**自保护面**,不是替你
|
|
12
|
+
做网关。wire 契约权威 = doc/98(Agent 会话服务 v2 契约冻结件)。
|
|
10
13
|
|
|
11
14
|
引擎负责传输与协议机器:11 个 `/v2` 端点路由、SSE 编帧与逐会话事件环形缓冲、
|
|
12
15
|
`Last-Event-ID` 重放、三类桥回执受理(工具/权限/提问)、多会话治理(保留窗/
|
|
@@ -70,9 +73,12 @@ const server = await startServer({
|
|
|
70
73
|
});
|
|
71
74
|
```
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
平台内置形工具面按应用 bundle 有效能力档**按位装配**(doc/112:TodoWrite /
|
|
77
|
+
AskUser / WebSearch / ImageGen / VideoGen 逐位随控制台「能力面」开关;逐会话远程
|
|
78
|
+
工具三桥受 `customTools` 位——位关而申报 `clientTools` 即 400 明告);应用平台类型
|
|
79
|
+
非「移动应用」时经 `onPlatformWarning` 与 `server.platform.warning` 帧提示
|
|
80
|
+
`app_platform_mismatch`(不拒)。`profile` 请求位不支持(带上会 400 明告)。
|
|
81
|
+
一体式可跑示例见仓内 `examples/serve-demo/agent-real.ts`。
|
|
76
82
|
|
|
77
83
|
## 三层鉴权定盘(架构拍板 2026-08-31)
|
|
78
84
|
|
|
@@ -104,8 +110,11 @@ const v2: AgentSessionsOptions = {
|
|
|
104
110
|
```
|
|
105
111
|
|
|
106
112
|
- **载荷**(`TurnEndNotifyPayload` 冻结形,恒不携消息内容恒不携凭据):
|
|
107
|
-
`{ sessionId, endUserId, turnId?, status: 'completed'|'aborted', lastSeq, ts }`
|
|
113
|
+
`{ sessionId, endUserId, turnId?, status: 'completed'|'aborted', reason?, lastSeq, ts }`
|
|
108
114
|
——`lastSeq` 供客户端持水位比对判断是否需要重放追赶,`ts` 供弃过期/防重放;
|
|
115
|
+
`reason?`(contract-v0.21 / RFC-SC-1)= 终局帧 `TerminalReason` 原值,单帧区分
|
|
116
|
+
`client_gone`(订阅者离场被策略止损)/ `internal_error`(内核故障)/ 用户中断
|
|
117
|
+
`aborted_*` 等,接收方按「已知值专项 + 未知值兜底」消费;
|
|
109
118
|
- **签名**:`secret` 在场时携 `x-tansr-signature: sha256=<hex>`
|
|
110
119
|
(`TURN_END_NOTIFY_SIGNATURE_HEADER`),对原始请求体全文 HMAC-SHA256——
|
|
111
120
|
接收端先验签再消费;
|
|
@@ -119,7 +128,9 @@ const v2: AgentSessionsOptions = {
|
|
|
119
128
|
## 主要出口
|
|
120
129
|
|
|
121
130
|
- `startServer(options)` / `StartServerOptions`(`v2?: AgentSessionsOptions`;
|
|
122
|
-
v2 缺席 = `/v2`
|
|
131
|
+
v2 缺席 = `/v2` 面零暴露;`v1?: V1SessionsOptions` = `/v1` 终结记录两级逐出
|
|
132
|
+
`governance.{retentionMs, maxRetainedSessions, sweepIntervalMs}`,**缺席 = 不逐出**
|
|
133
|
+
——库形态终结会话永驻可重放,长驻宿主建议 30 min / 10 000,CLI `tansr serve` 即此缺省);
|
|
123
134
|
- `AgentSessionsOptions`:`authenticate`(唯一鉴权缝)、`createSession`
|
|
124
135
|
(`AgentSessionFactory`)、`store`(`AgentStoreReader`)、`governance`、
|
|
125
136
|
`mediaMaxBodyBytes`、`onTurnEndNotify`;
|
|
@@ -132,6 +143,9 @@ const v2: AgentSessionsOptions = {
|
|
|
132
143
|
- 契约持份:`V2_LIMITS` / `V2_ERROR_CODE` / `CONTROL_FRAME` /
|
|
133
144
|
`AGENT_SESSION_CONTRACT_VERSION` / 请求体 zod schema 族 / 控制帧载荷类型;
|
|
134
145
|
- SSE 机器:`encodeSseFrame` / `encodeAgentStreamFrame` / `EventRingBuffer`;
|
|
146
|
+
- 事件日志接口缝(SC-35):`SessionEventLog` / `SessionEventLogRead` /
|
|
147
|
+
`SessionEventLogFactory` / `MemorySessionEventLog`(缺省实现),经
|
|
148
|
+
`AgentSessionsOptions.eventLog` 注入(见 Operations「扩展档位」);
|
|
135
149
|
- 轮末通知:`TurnEndNotifier` / `TURN_END_NOTIFY_SIGNATURE_HEADER`;
|
|
136
150
|
- i18n 便携面:`registerBuiltinLocales` / `setLocale` / `negotiateLocale`。
|
|
137
151
|
|
|
@@ -147,3 +161,360 @@ const v2: AgentSessionsOptions = {
|
|
|
147
161
|
devDeps,打包后守卫复检 manifest/依赖面/文件表/license(MIT + LICENSE 件
|
|
148
162
|
版权行 Tansr)+ **⑥真装配出口断言**(动态 import 验三出口可调 + wire/d.ts
|
|
149
163
|
锚);发布动作恒候用户口令。
|
|
164
|
+
|
|
165
|
+
## 宿主信号接线(Graceful shutdown)
|
|
166
|
+
|
|
167
|
+
`@tansr/serve` 以 npm 包嵌进你的 Node 进程,**进程信号归宿主**:引擎恒不在
|
|
168
|
+
`process` 上注册信号监听器(嵌入库不替宿主决定进程何时退出),也就不会替你
|
|
169
|
+
处理 SIGTERM/SIGINT——**宿主必须自己接线**。不接线的后果:编排器常规停机
|
|
170
|
+
(`docker stop` / systemd / Kubernetes 缺省都发 SIGTERM)= 进程被硬杀,全部
|
|
171
|
+
SSE 连接 reset、在飞轮丢、无收口日志。
|
|
172
|
+
|
|
173
|
+
`startServer` 返回的 `server.drain({ timeoutMs })`(幂等;缺省 30 s)按序做四件事:
|
|
174
|
+
① **拒新**——readiness 翻红(`server.readiness()` 供你的 `/readyz` 读)、新建/resume
|
|
175
|
+
一律 `503 draining` 携 `Retry-After`、全部在场 SSE 下发一帧 `retry:` 长间隔
|
|
176
|
+
(`sse.drainRetryMs`,缺省 10 s,客户端换台后再连);② **等在飞轮**——`/v2` 运行中的
|
|
177
|
+
轮至多等 `timeoutMs` 到终态,到点剩余 `interrupt()`(轮末 store 提交照走,resume 可
|
|
178
|
+
续);③ **flush 落盘**——等全部 fire-and-forget 的 store 提交/建行 settle
|
|
179
|
+
(`createAgentSessionFactory` 的 `flush`);④ **停机**——终结会话、停监听、掐空闲连接、
|
|
180
|
+
有界等待在飞非 SSE 请求答完(≤ 5 s)再掐残余连接。回执
|
|
181
|
+
`{ completedTurns, abortedTurns, flushedCommits, durationMs }` 可入日志。
|
|
182
|
+
`server.close()` 保持**硬收口**语义(立即中止在飞轮、不等落盘;drain 进行中调用则等
|
|
183
|
+
drain 完成);`/v1` 会话无粗态读面,drain 不等其在飞轮。
|
|
184
|
+
|
|
185
|
+
最小接线——两枚信号共用一个 handler,幂等位防重复进入:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
let stopping = false;
|
|
189
|
+
const shutdown = (signal: NodeJS.Signals): void => {
|
|
190
|
+
if (stopping) return;
|
|
191
|
+
stopping = true;
|
|
192
|
+
console.error(`[my-service] ${signal} received, draining...`);
|
|
193
|
+
server
|
|
194
|
+
.drain({ timeoutMs: 30_000 })
|
|
195
|
+
.then((report) => {
|
|
196
|
+
console.error(`[my-service] drained`, report);
|
|
197
|
+
process.exit(0);
|
|
198
|
+
})
|
|
199
|
+
.catch((error: unknown) => {
|
|
200
|
+
console.error('[my-service] shutdown failed', error);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
process.on('SIGTERM', shutdown);
|
|
205
|
+
process.on('SIGINT', shutdown);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
要点:
|
|
209
|
+
|
|
210
|
+
- **宽限期 ≥ drain 超时 + 收尾余量**:`drain({ timeoutMs: 30_000 })` 最坏等 30 s 在飞轮
|
|
211
|
+
+ ≤ 5 s 被中止轮收口 + ≤ 5 s 连接收口;容器 `stop_grace_period`(compose 缺省
|
|
212
|
+
10 s)、Kubernetes `terminationGracePeriodSeconds`、systemd `TimeoutStopSec` 部署件统一
|
|
213
|
+
**45 s**(doc/118 §八 G10;`deploy/serve-v2/` 与 `deploy/serve/` 已写),或把 `timeoutMs`
|
|
214
|
+
调小并同步调小宽限。宽限到点编排器 SIGKILL,等价硬杀;
|
|
215
|
+
- **信号要能到达 node**:容器 `CMD`/`ENTRYPOINT` 用 exec 形(JSON 数组)或
|
|
216
|
+
`--init`(tini 作 PID 1 转发),不要 `sh -c "node …"`(sh 当 PID 1 不转发);
|
|
217
|
+
- **退出码由你定**:引擎恒不调 `process.exit`;示例里 `drain()` 成功 `exit(0)`、
|
|
218
|
+
失败 `exit(1)`,与你的进程监督器约定一致即可;`tansr serve`(CLI 形态)同走
|
|
219
|
+
drain,超时经 `TANSR_SERVE_DRAIN_TIMEOUT_MS` 可配(缺省 30 s);
|
|
220
|
+
- **Windows 注记**:`process.on('SIGTERM')` 可注册但系统不会发出;本机开发以
|
|
221
|
+
Ctrl+C(SIGINT)收束,Linux/容器上两枚都会到;
|
|
222
|
+
- **与 `onTurnEndNotify` 的关系**:优雅关闭期出站通知恒不发(见上文投递纪律),
|
|
223
|
+
客户端靠回连 `Last-Event-ID` / history / resume 追赶。
|
|
224
|
+
|
|
225
|
+
完整可跑示例见仓内 `examples/serve-demo/server.ts`(接线封装在 `examples/serve-demo/runtime.ts`)。
|
|
226
|
+
|
|
227
|
+
## Operations(运维面:指标 / 健康 / 结构化日志 / request-id / 旋钮 / 部署)
|
|
228
|
+
|
|
229
|
+
以下全部属**运维面**,不属 doc/98 wire 契约(端侧不感知;三观测端点是否入契约候
|
|
230
|
+
G4-g 拍板)。数字口径与 SLO 词表见 `doc/report/serve并发审计-D路-可观测性与横向扩展.md` §七-1。
|
|
231
|
+
|
|
232
|
+
### 运维旋钮:`TANSR_SERVE_*` 环境变量 → 库选项(SC-32)
|
|
233
|
+
|
|
234
|
+
引擎的全部运维位都是 **`startServer` / 工厂 / governor 的库选项**;进程形态(容器、CLI
|
|
235
|
+
`tansr serve`)需要一个配置入口,本包给出**单一事实源** `resolveServeRuntimeOptions(env)`
|
|
236
|
+
(纯函数,不读 `process.env`、不写日志;表 `SERVE_RUNTIME_ENV` 与名单 `SERVE_RUNTIME_ENV_NAMES`
|
|
237
|
+
同出口)。宿主拿到结构化结果后各自展开:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import { createUpstreamGovernor, resolveServeRuntimeOptions, startServer } from '@tansr/serve';
|
|
241
|
+
|
|
242
|
+
const runtime = resolveServeRuntimeOptions(process.env);
|
|
243
|
+
for (const w of runtime.warnings) logger.error(`ignoring ${w.name}="${w.value}": expected ${w.expected}`); // 非法值:忽略,恒不拒启
|
|
244
|
+
const governor = createUpstreamGovernor(runtime.upstreamGovernor); // TANSR_SERVE_UPSTREAM_MAX_INFLIGHT → bulkhead
|
|
245
|
+
const build = createAgentSessionFactory({ cwd, logger, platform: { apiBaseUrl, appId, appKey, governor, ...runtime.session } }); // 分层超时 / 每轮墙钟
|
|
246
|
+
const server = await startServer({
|
|
247
|
+
host, port, token, createSession, logger: runtime.logFormat === 'json' ? jsonLogger : textLogger, // TANSR_SERVE_LOG_FORMAT
|
|
248
|
+
governor,
|
|
249
|
+
...runtime.server, // admission / http / sse / observability / eventBufferMaxBytes
|
|
250
|
+
v2: { authenticate, createSession: build.factory, store: build.storeReader,
|
|
251
|
+
...runtime.v2 }, // /v2 治理五键 + 分片码 sessionIdShard(TANSR_SERVE_SESSION_SHARD)
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
纪律(doc/118 §八 G5/G9 代拍):**任一变量缺席/空串即不落键 → 未设 = 库缺省,行为零漂移**;
|
|
256
|
+
非法值(非整数、越界、枚举外)进 `warnings` 由宿主一行告警后忽略,恒不拒启;`applied` 列出生效
|
|
257
|
+
的变量名,`appliedRuntimeEnvByGroup(runtime, 'governance', 'v2')` 可判某些组是否在场(CLI `tansr serve`
|
|
258
|
+
是 /v1 面,治理五键与分片码在场即提示无效)。全表 23 键(env → 库选项 → 缺省 → 建议值)见
|
|
259
|
+
`deploy/serve-v2/README.md` §三 与 `deploy/serve-v2/.env.example`;分组:`admission`(4)/ `http`(1)/
|
|
260
|
+
`observability`(1)/ `ring`(1)/ `governance`(5)/ `v2`(1,分片码)/ `upstream`(1)/ `session`(4)/
|
|
261
|
+
`sse`(3)/ `log`(1)。不在表内(语义归各自入口):`TANSR_SERVE_TOKEN`(/v1 Bearer)、
|
|
262
|
+
`TANSR_SERVE_DRAIN_TIMEOUT_MS`(drain 超时,缺省 30 s)、`TANSR_SERVE_V1_RETENTION_MS` /
|
|
263
|
+
`TANSR_SERVE_V1_MAX_RETAINED`(SC-18)、`TANSR_SERVE_MCP_CONNECTIONS`(SC-31;缺省 1、上限 16。
|
|
264
|
+
**SC-44 起只是进程级兜底层**:`mcpServers.<name>.pool.connections` 逐台键 > 程序面 `pool` 选项 > 本 env
|
|
265
|
+
> 缺省 1,合并在 kernel `governance.ts` 单点;写了逐台键的服务器不受本 env 影响,有会话态的服务器
|
|
266
|
+
恒保持 1——见 doc/60 §2.5)。
|
|
267
|
+
|
|
268
|
+
### 部署参考件与优雅关闭时序(SC-32 / SC-32c;doc/118 §八 G1/G2/G10)
|
|
269
|
+
|
|
270
|
+
`deploy/serve-v2/` 是 /v2 npm 嵌入形态的**参考宿主**部署件(`examples/serve-demo` 两入口打成单
|
|
271
|
+
文件镜像;compose N 副本 + nginx 分片路由/SSE 模板;K8s StatefulSet + 内层 nginx + ingress-nginx 注解 +
|
|
272
|
+
NetworkPolicy);`deploy/serve/` 是 CLI `tansr serve` /v1「一容器一智能体实例」模板。要点:
|
|
273
|
+
|
|
274
|
+
- **本包不是网关**:TLS 终结 / HTTP2 / IP 级限流 / WAF 归你的边缘(G10 反代前置);nginx 模板只做
|
|
275
|
+
SSE 直通(`proxy_buffering off`、读超时 ≥ 心跳 15 s × 4、对 `text/event-stream` 不压缩、
|
|
276
|
+
`proxy_next_upstream off`)与分片前缀路由,`client_max_body_size 20m` 与引擎体帽对齐。
|
|
277
|
+
- **多副本 = 分片前缀路由 + 副本私有存储根**(G2 先 (a) 档;SC-32b/SC-32c):会话运行态在副本内存 +
|
|
278
|
+
私有 store,`/v2/sessions/<id>/…` 必须回到创建副本。每副本持分片码 `v2.sessionIdShard`(0–255,
|
|
279
|
+
= 副本序号;env `TANSR_SERVE_SESSION_SHARD`),引擎给**全新会话**生成的 sessionId 前两位十六进制
|
|
280
|
+
恒 = 分片码(其余 30 位仍是 UUID v4 随机位,形制仍是 UUID;resume/attach 沿用原 id),反代读 id
|
|
281
|
+
前两位做静态 `map` 路由回创建副本;创建请求(路径无 id)任意副本轮询。**客户端零改动**(sessionId
|
|
282
|
+
对客户端恒不透明,doc/98 §五-5.5),无 cookie、无端侧头、不按 Authorization 哈希(令牌刷新不
|
|
283
|
+
漂移)。**不能按路径 sessionId 做一致性哈希**——创建请求没有 sessionId,会话生在随机副本,后续按
|
|
284
|
+
sid 哈希落别处 → 404 → L3 resume 读不到 → 分叉;前缀路由把「id 决定副本」反过来变成「副本决定 id」。
|
|
285
|
+
副本序号即分片码:扩缩容不改既有映射;缩容副本上的会话随之丢失(与任何本地态服务同律,先 drain)。
|
|
286
|
+
自定义 `AgentSessionFactory` 须采用 `init.sessionId`,忽略即失去粘性并收 `session.id_hint_ignored`
|
|
287
|
+
告警。副本故障 = 其上会话不可迁移(客户端按 404 → 新建)。**恒不共享存储目录**(NFS/EFS/同一卷挂
|
|
288
|
+
两副本):kernel 会话锁按本机 PID 判活,容器 PID 命名空间下跨副本必然误判——`SESSION_LOCKED` 30 min
|
|
289
|
+
或并发写同一 journal → `500 store_corrupted`;这是不安全配置而非「慢一点」。
|
|
290
|
+
- **优雅关闭时序**(宽限 45 s):t0 SIGTERM(+ K8s 摘 Endpoints)→ 拒新(`/readyz` 503、新建/resume
|
|
291
|
+
`503 draining` + `Retry-After`、SSE 下发长 `retry:`)→ 等在飞轮 ≤ `TANSR_SERVE_DRAIN_TIMEOUT_MS`
|
|
292
|
+
(缺省 30 s,到点 `interrupt()`,轮末 store 提交照走)→ flush 落盘 → 停监听 ≤ 5 s + 连接收口 ≤ 5 s →
|
|
293
|
+
宿主 `governor.close()` → exit 0;t0+45 s 未退出 → SIGKILL。宽限恒 ≥ drain 超时 + 15 s。
|
|
294
|
+
- **观测端点暴露**:容器绑 `0.0.0.0` 时须 `TANSR_SERVE_EXPOSE_OBSERVABILITY=1`(参考镜像已设),探针
|
|
295
|
+
与抓取器走内网直连副本;反代恒不把 `/metrics` `/healthz` `/readyz` 转到公网。
|
|
296
|
+
- **容量估算**(本机现状档不外推;doc/118 §一-1.1):单进程 ≈2500 活跃 SSE 连接时 ELD p99 ≈190 ms、
|
|
297
|
+
5000 连接 ≈250 ms;≈35 KiB 堆/会话(全装配链);企业档参考帽 `maxActiveSessions 5000` /
|
|
298
|
+
`maxSseConnections 10000` / `maxInflightBodyBytes 256 MiB` / ELD p99 200 ms(G5,仅文档)。
|
|
299
|
+
- **`fetchImpl` 须尊重 `init.signal`**:宿主自带 fetch(代理/埋点)包在 governor 外层或内层都可,但取消
|
|
300
|
+
传播链在此不得断——drain 到点的 `interrupt()`、每轮墙钟、分层超时都靠 AbortSignal 抵达上游。
|
|
301
|
+
|
|
302
|
+
### 扩展档位(横向扩展的三级;doc/118 §八 G2 / §11.2)
|
|
303
|
+
|
|
304
|
+
| 档 | 形态 | 状态 | 重放窗(`Last-Event-ID`)在哪 |
|
|
305
|
+
|---|---|---|---|
|
|
306
|
+
| (a) | 单进程 + 分片粘性:`v2.sessionIdShard` + 反代前缀路由,副本私有存储根,恒不共享目录 | **已落**(SC-32b/c;上节) | 创建副本内存(`MemorySessionEventLog` = 环形缓冲,条数 1024 + 可选字节帽) |
|
|
307
|
+
| (b) | 事件日志外置:`AgentSessionsOptions.eventLog` 注入 `SessionEventLog` 实现(Redis Streams / NATS JetStream 等) | **接口缝已落**(SC-35);实现**另包候需求**,包名属命名面留用户 | 外部日志;任一副本可按 sessionId 重放,会话运行态(驱动/桥/在飞轮)仍在创建副本——分片路由仍需要 |
|
|
308
|
+
| (c) | 会话态外置:驱动/桥/历史随会话迁移到任一副本 | **未立项** | 外部 |
|
|
309
|
+
|
|
310
|
+
(b) 档的缝:`eventLog: (ctx: { sessionId, endUserId }) => SessionEventLog`,每次纳管一枚;引擎的泵写入
|
|
311
|
+
(`append(event, frameBytes?)` 返回事件自带的 seq)、`Last-Event-ID` 重放与 gap 帧判定(`readAfter(afterSeq)`
|
|
312
|
+
一趟返回 `{ events, oldestRetainedSeq?, dropped, gap }`)、`ringStats` 汇总(`stats()`)、记录离开内存
|
|
313
|
+
(`close()`,保留窗到点 / 数量帽 / resume 让位 / `closeAll` 各恰一次)只经此接口。**缺席 = 内存实现,行为
|
|
314
|
+
逐字节同今**(`eventBufferSize` / `eventBufferMaxBytes` 仍施于缺省实现;传了工厂即由实现定容量与逐出)。
|
|
315
|
+
实现纪律:全部方法同步(泵与订阅的同步原子块依赖此契约,异步后端须在接口之下自做写缓冲/本地镜像);seq
|
|
316
|
+
由句柄分配、随事件携带,日志恒不改写(外部存储的自生成 id 只能作游标);引擎恒不引 zod 之外第三方,任何
|
|
317
|
+
外置实现都在别的包里。
|
|
318
|
+
|
|
319
|
+
### 分层存储(冷层):会话历史落到 S3 / OSS / KV / 关系库(doc/119 IO-21 / doc/120;IO-32 集成后可用)
|
|
320
|
+
|
|
321
|
+
会话历史的耐久性地板恒是**本地热层**(轮末 journal 追加,fsync + rename ≈ 13 ms/轮);热层不可能配无限硬盘,
|
|
322
|
+
所以既有 store 工厂加了**可选**冷层:热层达到封段阈值(缺省 16 MiB / 5000 记录)后把这一截封成**不可变的段对象**
|
|
323
|
+
(JSONL + gzip,首尾哈希链)上传到你挂的存储,再以 CAS 重写一份**清单**(唯一提交点);已提交段可被热盘 LRU 逐出,
|
|
324
|
+
读侧按需回源。**不挂冷层 = 今日行为,字节等价**;客户端零感知(`/v2` wire 零改动)。
|
|
325
|
+
|
|
326
|
+
**五行接入**(`createAgentSessionFactory({ store })` 不改;doc/91 冻结接口不改):
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
import { createServeAgentSessionStore, createAgentSessionFactory } from '@tansr/serve';
|
|
330
|
+
import { createS3BlobStore } from './store-s3.js'; // 你的适配器(参考实现 examples/store-s3,零依赖 fetch + SigV4)
|
|
331
|
+
|
|
332
|
+
const store = createServeAgentSessionStore({
|
|
333
|
+
dir: '/var/lib/tansr/hot', // 热层:本地盘,恒不共享
|
|
334
|
+
cold: createS3BlobStore({ bucket, region, credentials }), // 一级 SegmentBlobStore(或二级 SessionHistoryStore)
|
|
335
|
+
policy: { tenant: 'acme', hotRetention: { maxBytes: 20 * 2 ** 30 } },
|
|
336
|
+
logger, // 冷层事件 → 结构化日志 store.*
|
|
337
|
+
});
|
|
338
|
+
const build = createAgentSessionFactory({ cwd, store, platform: { ... } });
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**两级选择**:一级 `SegmentBlobStore`(5 个字节方法 `put / get / head / list / delete` + 一次能力自述
|
|
342
|
+
`capabilities`;S3 / OSS / COS / MinIO / R2 / GCS / Azure / GridFS / 文件系统皆此级,引擎内置分段、编码、清单 CAS、
|
|
343
|
+
热盘 LRU、重试、指标)——**推荐**;二级 `SessionHistoryStore`(`store / load / listSessions / remove`,自管布局;
|
|
344
|
+
KV / 关系库更贴合)。适配器唯一要遵守的错误约定是 `StoreError.kind` 四类(`transient` / `permanent` /
|
|
345
|
+
`not_found` / `precondition_failed`;其他异常一律按 transient)。一级冷层 `conditionalPut:false` 或 `list:'none'`
|
|
346
|
+
时**必须**再挂 `index: SessionIndexStore`(多副本共享会话元 / CAS 落点),否则构造期 fail-fast(不静默降级为不安全提交)。
|
|
347
|
+
随包实现:`createMemoryBlobStore()`(测试替身)、`createFsBlobStore({ dir })`(目录模拟对象存储;**只可作冷层**)。
|
|
348
|
+
一致性测试套 `runStorageConformance(store)` 认证你的适配器(fs / memory / S3 同一套)。
|
|
349
|
+
|
|
350
|
+
**旋钮(`policy`)**:
|
|
351
|
+
|
|
352
|
+
| 键 | 缺省 | 语义 |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| `codec` | `'jsonl+gzip'` | 段编码;`'jsonl+zstd'` 仅 Node ≥ 22.15 可用 |
|
|
355
|
+
| `hotRetention.maxBytes` | 不设 | 热盘上本地段文件占用帽:**已提交**段先逐出;仍越帽 → `readiness()` 红 + `store.hot_full` 事件;**未上传段恒不删** |
|
|
356
|
+
| `hotRetention.keepRecentSegments` | 引擎缺省 | 逐出时保留的尾段数(压缩后 resume 常只需摘要 + 尾段) |
|
|
357
|
+
| `upload.mode` / `concurrency` | `'async'` / 4 | 热层先 ack 再异步上传;并发受约束(A12) |
|
|
358
|
+
| `upload.retryBudget` | 8 次 / 退避封顶 60 s | transient 重试预算;耗尽 → `store.commit_failed{gaveUp:true}` + `onStoreError` |
|
|
359
|
+
| `restore.prefetchTailSegments` | 1 | 热层全失重建后预取的尾段数 |
|
|
360
|
+
| `transform` | 无 | `encode / decode` 字节流挂点:加密 / 脱敏归你(引擎不做密码学) |
|
|
361
|
+
| `keyMapper` | 恒等 | 规范键 `<tenant>/<endUserKey>/<sessionId>/<volumeId>/…` → 你的键(不得破坏唯一性) |
|
|
362
|
+
| `tenant` | `'default'` | 规范键首段(多租户隔离) |
|
|
363
|
+
|
|
364
|
+
`metrics` 缺省 kernel `defaultMetrics`(与 `/metrics` 同源);`index` 见上;`onStoreEvent(event)` 原样订阅冷层事件;
|
|
365
|
+
`onStoreError(sessionId, error)` 只在**放弃**时回调(热层已 ack,恒不抛给调用方)。
|
|
366
|
+
|
|
367
|
+
**失败语义**:上传 / 清单提交失败**恒不回滚热层**(轮末已 ack 的历史耐久不变)——按预算重试,超预算记
|
|
368
|
+
`store.commit_failed` + 积压指标,段留在热盘、下次绑定时对账补传;`precondition_failed`(清单被另一副本接管)本写者
|
|
369
|
+
放弃不重试(A2 / A13)。读侧:回源段篡改 / 截断 / 跳段(`permanent`)→ `500 store_corrupted`,恒不静默续接;冷层
|
|
370
|
+
暂不可达(`transient`)原样上抛 `StoreError`(不冒充损坏)。**热层全失(节点重建,A10)**:同一冷层重开 store,
|
|
371
|
+
`GET /v2/sessions` 列表见会话、resume 经冷清单 → 段 → 重灌成功(只有**已封存**的段可恢复;未越阈值的活段随热层丢失——
|
|
372
|
+
需要更细粒度的耐久点请调小 `segmentation` 阈值)。`delete` 先删冷层再删热层。
|
|
373
|
+
|
|
374
|
+
**两条告警线**(`/metrics`,`tansr_kernel_store_*` 族:`ops_total{op,result}` / `bytes_total{op}` / `latency_ms{op}` /
|
|
375
|
+
`failures_total{kind}` / `backlog_bytes` / `hot_bytes`):
|
|
376
|
+
① `tansr_kernel_store_backlog_bytes` **持续增长** = 冷层不可达或写失败(热层仍在服务,但保留窗只剩本地盘);
|
|
377
|
+
② `tansr_kernel_store_hot_bytes` **逼近 `hotRetention.maxBytes`** = 即将触顶——触顶后 `/readyz` 503(理由
|
|
378
|
+
`store_hot_full`,`v2.store.readiness` 经工厂 storeReader 转发即自动接线;或把 `store.readiness` 直接挂
|
|
379
|
+
`observability.readinessProbes`),LB 摘流、新建改落别的副本。结构化日志:`store.segment_sealed` /
|
|
380
|
+
`store.segment_committed` / `store.commit_failed{kind,attempts,gaveUp}` / `store.hot_full` / `store.hot_recovered` /
|
|
381
|
+
`store.evicted` / `store.orphan_gc`(字段恒不含消息内容)。
|
|
382
|
+
|
|
383
|
+
**部署建议**:热层恒**本地盘、恒不共享**(共享盘是不安全配置,见上节);冷层用**生命周期规则**承接保留窗
|
|
384
|
+
(`<tenant>/<endUserKey>/<sessionId>/<volumeId>/` 前缀按 `expiresAt` / 标签过期;压缩换卷 = 新 `volumeId`,旧卷独立
|
|
385
|
+
过期不影响新卷);冷层里的段是**明文 JSONL(压缩)**——加密、密钥管理、驻留地、访问控制归你(`transform` 挂点),
|
|
386
|
+
`endUserKey` 是哈希不是身份,不要把 endUserId 明文放进键或标签;多副本下配 `index`(会话元共享、`list` 便宜),
|
|
387
|
+
`policy.tenant` 按租户隔离前缀。优雅关闭:drain 第三阶段在工厂 flush 之后再 `await store.flush({ timeoutMs })`
|
|
388
|
+
等冷层队列排空(返回 `false` = 超时仍有积压,热层已耐久,重启后对账补传)。
|
|
389
|
+
|
|
390
|
+
### 三观测端点(免 Bearer;缺省只在回环监听时暴露)
|
|
391
|
+
|
|
392
|
+
| 端点 | 语义 | 响应 |
|
|
393
|
+
|---|---|---|
|
|
394
|
+
| `GET /healthz` | liveness:进程活着即 200(关闭期亦 200,摘流看 readyz) | `{"status":"ok"}` |
|
|
395
|
+
| `GET /readyz` | readiness:`!closing ∧ 全部就绪探针 ready` | 200 `{"ready":true,"reasons":[]}` / 503 `{"ready":false,"reasons":["closing",…]}` |
|
|
396
|
+
| `GET /metrics` | OpenMetrics 1.0 文本(`application/openmetrics-text; version=1.0.0`) | 内核 + serve 指标一份 exposition,末行 `# EOF` |
|
|
397
|
+
|
|
398
|
+
暴露纪律:三端点**免 Bearer**,所以暴露面必须显式——`host` 为回环(127.x / localhost /
|
|
399
|
+
::1)时缺省暴露;绑 `0.0.0.0` / 公网地址时缺省**不暴露**,须 `observability: { expose: true }`
|
|
400
|
+
(或分别 `exposeHealth` / `exposeMetrics`)。未暴露时这三条路径**落回既有流程**,与任意
|
|
401
|
+
未知路径响应完全一致(无 Bearer 401 / 有 Bearer 404),不泄漏存在性。容器/K8s 部署把
|
|
402
|
+
它们只发布给探针与抓取器所在网络(反代前置时不要把 `/metrics` 转到公网)。
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
const server = await startServer({
|
|
406
|
+
// ...
|
|
407
|
+
observability: {
|
|
408
|
+
expose: true, // 绑 0.0.0.0 时显式放开
|
|
409
|
+
readinessProbes: [() => (storeWritable ? true : { ready: false, reason: 'store_unwritable' })],
|
|
410
|
+
requestLogSampleRate: 0.1, // 高并发下调请求日志采样;错误级事件恒不采样
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
server.stats(); // 结构化快照:sessions{active,byFace,pending?,retainedEnded?} / sse / http / eventLoopDelayMs / memory / notifier? / readiness
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### 指标(首批;名恒 `tansr_` 前缀,`process_resident_memory_bytes` 为唯一标准名例外)
|
|
417
|
+
|
|
418
|
+
内核层(五形态共享,TUI/Headless/serve/acp/SDK 同一份进程级读数;kernel 出口
|
|
419
|
+
`defaultMetrics` / `createMetricsRegistry` / `renderOpenMetrics` 经本包再出口):
|
|
420
|
+
`tansr_kernel_model_calls_total{result}`、`tansr_kernel_model_call_duration_ms`、
|
|
421
|
+
`tansr_kernel_tool_executions_total{result}`、`tansr_kernel_tool_duration_ms`、
|
|
422
|
+
`tansr_kernel_turns_total{reason}`、`tansr_kernel_internal_errors_total`、
|
|
423
|
+
`tansr_kernel_compactions_total{mechanism}`、`tansr_kernel_retries_total{class}`。
|
|
424
|
+
|
|
425
|
+
serve 层:`tansr_serve_http_requests_total{method,route,status}`(route 恒模板化,如
|
|
426
|
+
`/v2/sessions/:id/events`;`status="0"` = 响应头未发出即断开)、
|
|
427
|
+
`tansr_serve_http_request_duration_ms{route}`、`tansr_serve_rejections_total{code}`
|
|
428
|
+
(401/403/404/405/409/410/413/422/429/4xx/5xx)、`tansr_serve_sessions_active{face=v1|v2}`、
|
|
429
|
+
`tansr_serve_sessions_pending`、`tansr_serve_sessions_retained_ended`、
|
|
430
|
+
`tansr_serve_sse_connections_active`、`tansr_serve_sse_frames_written_total`、
|
|
431
|
+
`tansr_serve_replay_gap_total`、`tansr_serve_ring_evictions_total`、
|
|
432
|
+
`tansr_serve_event_loop_delay_ms{stat=p50|p99|max}`(两次采集之间的窗口)、
|
|
433
|
+
`process_resident_memory_bytes`、`tansr_serve_webhook_deliveries_total{result}`、
|
|
434
|
+
`tansr_serve_store_commit_errors_total` / `tansr_serve_store_create_errors_total`、
|
|
435
|
+
`tansr_serve_token_mint_failures_total`。
|
|
436
|
+
|
|
437
|
+
纪律:指标标签**恒不含** sessionId / endUserId / 令牌 / 消息内容(高基数与凭据禁令);
|
|
438
|
+
未知路径一律 `route="other"`。同进程起多台 `startServer` 且共享缺省注册表时,gauge 族由最后
|
|
439
|
+
采集者覆写、counter 族累加——需按实例隔离读数时各传 `observability.metrics: createMetricsRegistry()`。
|
|
440
|
+
|
|
441
|
+
### 结构化日志与 request-id
|
|
442
|
+
|
|
443
|
+
`ServeLogger` 新增可选 `log(level, event, fields)`(旧 `info/error` 保留)。事件名恒英文
|
|
444
|
+
机器码(`LOG_EVENT` 词表:`request.completed` / `request.unhandled` / `sse.opened` /
|
|
445
|
+
`sse.closed{reason}` / `store.commit_failed` / `store.create_failed` / `notify.delivery_failed` /
|
|
446
|
+
`server.listening` / `server.closed` …;分层存储冷层在场时另有 `store.segment_sealed` /
|
|
447
|
+
`store.segment_committed` / `store.hot_full` / `store.hot_recovered` / `store.evicted` / `store.orphan_gc`,
|
|
448
|
+
见上节),字段恒不含令牌与消息内容(`sessionId` 可在场,
|
|
449
|
+
`endUserId` 仅 /v2 已鉴权时由路由层回填)。只有旧形 `info/error` 的 logger 会收到回落:
|
|
450
|
+
带人读文案的事件(listening/closed/兜底错误)仍是既有 i18n 文案行,其余事件为**一行 JSON**
|
|
451
|
+
`{"level","event",...fields}`。
|
|
452
|
+
|
|
453
|
+
request-id:每个请求采纳合法入站 `x-request-id`(`^[A-Za-z0-9._-]{1,128}$`),否则取
|
|
454
|
+
W3C `traceparent` 的 trace-id,再否则自铸 UUID;恒回响应头 `x-request-id`(含 401/404),
|
|
455
|
+
并作 `request.completed{requestId, method, route, status, durationMs}` 与
|
|
456
|
+
`request.unhandled` 的关联键。SSE 长连接在连接关闭时发一次 `request.completed`(`sse:true`)。
|
|
457
|
+
`createAgentSessionFactory` 的 `onStoreError` 缺省不再静默:一行结构化 JSON 到 stderr 并计数。
|
|
458
|
+
|
|
459
|
+
### 容量基线与门禁
|
|
460
|
+
|
|
461
|
+
`bench/serve/`(A/B/C 路审计脚本)已门禁化:`pnpm bench:serve:gate` 按 `bench/serve/thresholds.json`
|
|
462
|
+
现状档断言(本机数字为参考、不外推;目标档随 G12 回填),详见 `bench/serve/README.md`。
|
|
463
|
+
|
|
464
|
+
**容量模型**:「挂着的会话数」≠「同时活跃数」——订阅者全部离场 `orphanGraceMs`(60 s)后中止当前轮、再
|
|
465
|
+
`idleAfterGoneMs`(5 min)后落盘退出内存,resume 复活;内存与 CPU 只随同时活跃的会话增长。单进程天花板是
|
|
466
|
+
单事件循环 CPU(几千会话同时流式),准入帽(`admission`)让它有界拒绝而非拖垮,`v2.sessionIdShard` 分片前缀
|
|
467
|
+
路由随时加副本。**部署前置**:每条 SSE 占 1 个文件句柄,Linux 缺省 `nofile 1024` 会把并发订阅者卡在一千出头
|
|
468
|
+
并报 `EMFILE`——按目标并发 ≥ 2× 抬(`deploy/serve-v2` 模板已置 65536,nginx 侧 `worker_connections` 同律)。
|
|
469
|
+
|
|
470
|
+
### 上游治理(平台内置形:铸令牌闸 / bundle 缓存 / 503 语义 / governor 注入)
|
|
471
|
+
|
|
472
|
+
平台内置形(`createAgentSessionFactory({ platform })`)的上游链是
|
|
473
|
+
`appid/appkey → POST /v1/app-tokens(per-endUser 令牌)→ GET /t1/config + POST /t1/heartbeat
|
|
474
|
+
(bundle/features)→ POST /t1/exchange(模型)`。Wave 2(SC-22/23/24/30)起这条链有了治理位;
|
|
475
|
+
缺省值全部为建议值(候 G3 拍板),`upstreamStats()` 可读:
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
const build = createAgentSessionFactory({
|
|
479
|
+
cwd,
|
|
480
|
+
logger, // 缺省提示帧承接经 platform.warning 结构化事件(缺席回落 console.warn)
|
|
481
|
+
platform: {
|
|
482
|
+
apiBaseUrl, appId, appKey,
|
|
483
|
+
tokenMinter: { // 铸令牌治理(SC-23/SC-30;全部可选)
|
|
484
|
+
maxConcurrentMints: 32, // 全局并发闸:同时出网的 /v1/app-tokens 数(重启惊群从 N 降到闸值)
|
|
485
|
+
maxQueuedMints: 1024, // 闸等待队列;满额即刻 503 overloaded(不出网)
|
|
486
|
+
mintQueueTimeoutMs: 10_000, // 排队超时 → 503 overloaded
|
|
487
|
+
expiryJitterRatio: 0.15, // 令牌 expiresAt 只提前不推后的抖动比(打散 TTL−300 s 的对齐刷新)
|
|
488
|
+
negativeCacheInitialMs: 5_000, negativeCacheMaxMs: 60_000, // 失败负缓存 5 s 起指数 ×2 至 60 s
|
|
489
|
+
maxCachedTokens: 100_000, // 令牌缓存 LRU 帽(过期项读到即删)
|
|
490
|
+
},
|
|
491
|
+
bundleCacheTtlMs: 60_000, // bundle/features 缓存 TTL 上界(SC-24;0 = 每会话拉取)。也可传 bundleCache 实例跨工厂共享
|
|
492
|
+
governor, // SC-22:providers createUpstreamGovernor 产物;三处出站同一枚(见下)
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
build.upstreamStats?.(); // { tokenMinter: { inflight, queued, mints, hits, negativeHits, staleServed, failures{kind}, cached, evicted }, bundleCache?: { hits, misses, revalidations, notModified, inflightDedup, … } }
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
- **铸令牌闸 / 抖动 / 负缓存**:500 endUser 同时首连时 `/v1/app-tokens` 在飞峰值 = 闸值(此前 500);
|
|
499
|
+
同批铸出的令牌不再同刻进入刷新窗;某 endUser 铸造失败后窗内不再出网(直接回同一错),
|
|
500
|
+
刷新失败但旧令牌仍在时效内直接沿用旧令牌;`warm(endUserIds)` 可在启动期预热。
|
|
501
|
+
- **bundle 缓存**:键 `(apiBaseUrl, appId)`,TTL = min(平台 `max-age`, `bundleCacheTtlMs`);过期后
|
|
502
|
+
`If-None-Match` 条件重拉(304 只续期);features(heartbeat)按 endUser 每 TTL 一次而非每会话。
|
|
503
|
+
建会话上游往返:冷 endUser 3 → 1(仅铸令牌;首次 +1 config),热 endUser 2 → 0。
|
|
504
|
+
**一致性 SLA**:控制台改模型授权/别名后在网关生效的延迟上界 = `bundleCacheTtlMs`(缺省 60 s);
|
|
505
|
+
要更快生效就调小(平台 `max-age` 仍是下界之一),要即时一致传 `0`。
|
|
506
|
+
- **503 `upstream_unavailable`**(候 doc/98 §七 增笔 G4-a):平台铸令牌撞 429 / 5xx / 网络错时建会话
|
|
507
|
+
回 `503 upstream_unavailable` 并携 `Retry-After`(取平台 `Retry-After`,缺席按 `admission.retryAfterMs`
|
|
508
|
+
5 s);客户端按 5xx 语义退避重试即可。与 `503 overloaded`(本机闸忙 / 准入帽)分开:一个是「上游坏,
|
|
509
|
+
加压无益」,一个是「本机忙,稍后再来」。4xx(凭据/入参被拒)维持 `400 create_failed`(重试无益)。
|
|
510
|
+
指标 `tansr_serve_token_mint_failures_total` 计每次上游铸造失败(负缓存命中不重复计)。
|
|
511
|
+
- **governor 注入(SC-22 serve 半场)**:治理核(显式 `undici.Agent` 连接上限 / per-origin 隔离舱 /
|
|
512
|
+
熔断 / 重试预算 / 本地令牌桶)落 `@tansr/providers` `createUpstreamGovernor(options)`;serve 只负责把
|
|
513
|
+
**同一枚**进程级实例的 `fetch` 注入三处出站——`platform.governor`(铸令牌器 + bundle 缓存 + 模型
|
|
514
|
+
client)与 `startServer({ governor })`(轮末 webhook 通知机)。推荐:一进程一枚,`connections` 按平台
|
|
515
|
+
连接配额、`keepAliveTimeout ≥ 60 s`;宿主持有其生命周期(`governor.close()` 在 `server.drain()` 之后
|
|
516
|
+
由宿主调用,serve 恒不代关);宿主自实现 `fetch` 时必须尊重 `init.signal`(取消传播链在此不得断)。
|
|
517
|
+
未传 governor = 直连 `fetch`(行为零漂移)。
|
|
518
|
+
- **G3 平台限流上报**:平台现行限流键绑 app key(`/t1/exchange` 120/min)+ 源 IP(600/min);网关形态
|
|
519
|
+
下全部 endUser 共用一把 key、一个出口 IP,本节的闸/缓存只能削峰、不能抬顶——`upstream_unavailable`
|
|
520
|
+
持续出现且 `Retry-After` 对齐到分钟整点,即触顶信号,应上报平台侧调档(总卷 G3 / C 路 D-1)。
|