Skip to content

Commit bbbea48

Browse files
margropclaude
andcommitted
feat: 增加同一模型 AI 调用并发上限,超出的调用排队
- 同一渠道、地址和模型同时进行的 AI 调用默认不超过 5 个,超出的按先后顺序排队,避免服务商返回 429 - 管理员可在「设置 → 用户管理」修改上限(1~100),也可用环境变量 AI_CONCURRENCY_LIMIT 设置默认值 - 上限在每次取号时读取配置,修改后立即生效;调用失败也会释放名额 - 同步更新 .env.example、README 与 Wiki Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dfe4bb8 commit bbbea48

18 files changed

Lines changed: 421 additions & 7 deletions

‎.env.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ AUTH_TRUST_HOST=true
2424
# 普通用户每天可发起的 AI 操作次数(默认 0,不限制;管理员不受限制)。网页中「设置 → 用户管理」保存的值优先
2525
# AI_DAILY_LIMIT=0
2626

27+
# 同一模型同时进行的 AI 调用上限(默认 5,范围 1~100),超出的调用排队,避免服务商返回 429。网页中「设置 → 用户管理」保存的值优先
28+
# AI_CONCURRENCY_LIMIT=5
29+
2730
# 动画语音讲解的朗读方式(默认 browser):browser 为浏览器本地语音;minimax、qwen、glm、stepfun、volcengine、volcengine-agent、openai 为云端语音,复用下方对应渠道的 API Key。
2831
# 网页中「设置 → AI 设置 → 语音讲解」保存的值优先
2932
# TTS_MODE=browser

‎README.en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ The most common variables are below; see the collapsed table and [`.env.example`
400400
| `TTS_MODE` | How animation narration is voiced, `browser` (browser voice) by default |
401401
| `ALLOW_REGISTRATION` | Whether sign-ups are open, `false` by default |
402402
| `AI_DAILY_LIMIT` | Daily AI actions per regular user, `0` (unlimited) by default |
403+
| `AI_CONCURRENCY_LIMIT` | Concurrent AI calls per model, `5` by default; further calls queue |
403404

404405
<details>
405406
<summary><strong>Full list of environment variables</strong></summary>
@@ -417,6 +418,7 @@ The most common variables are below; see the collapsed table and [`.env.example`
417418
| `HTTP_PROXY` / `HTTPS_PROXY` | Proxy | None | Proxy for requests to AI services |
418419
| `ALLOW_REGISTRATION` | Allow sign-ups | `false` | When `true`, registration is open by default; the switch saved by an admin under **Settings → User Management** takes precedence |
419420
| `AI_DAILY_LIMIT` | Daily AI limit per user | `0` (unlimited) | AI actions each regular user can start per day; admins are not limited. The value saved under **Settings → User Management** takes precedence |
421+
| `AI_CONCURRENCY_LIMIT` | AI concurrency per model | `5` | How many AI calls the same provider, address and model may run at once (1–100). Further calls wait in a queue, avoiding 429 errors from the provider. The value saved under **Settings → User Management** takes precedence |
420422
| `TTS_MODE` | How animation narration is voiced | `browser` | `browser` uses the browser's voice; `minimax`, `qwen`, `glm`, `stepfun`, `volcengine`, `volcengine-agent` or `openai` uses a cloud voice with that channel's API key (Doubao Speech uses `VOLCENGINE_TTS_API_KEY`; OpenAI uses the active OpenAI configuration). The value saved under **Settings → AI → Voice narration** takes precedence |
421423

422424
**AI** (settings saved in the web UI take precedence over environment variables)

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ GeoGebra 的文字、不等式和标签参数按共用命令清单校验,重
398398
| `TTS_MODE` | 动画语音讲解的朗读方式,默认 `browser`(浏览器本地语音) |
399399
| `ALLOW_REGISTRATION` | 是否开放注册,默认 `false` |
400400
| `AI_DAILY_LIMIT` | 普通用户每天的 AI 次数,默认 `0`(不限制) |
401+
| `AI_CONCURRENCY_LIMIT` | 同一模型同时进行的 AI 调用上限,默认 `5`,超出的排队 |
401402

402403
<details>
403404
<summary><strong>完整环境变量列表</strong></summary>
@@ -415,6 +416,7 @@ GeoGebra 的文字、不等式和标签参数按共用命令清单校验,重
415416
| `HTTP_PROXY` / `HTTPS_PROXY` | 代理 | 无 | 访问 AI 服务时使用的代理 |
416417
| `ALLOW_REGISTRATION` | 允许注册 | `false` | 为 `true` 时默认开放注册;管理员在「设置 → 用户管理」中保存的开关优先 |
417418
| `AI_DAILY_LIMIT` | 每人每日 AI 次数 | `0`(不限制) | 普通用户每天可发起的 AI 操作次数,管理员不受限制;「设置 → 用户管理」中保存的值优先 |
419+
| `AI_CONCURRENCY_LIMIT` | 同一模型 AI 并发上限 | `5` | 同一渠道、地址和模型同时进行的 AI 调用数量(1~100),超出的调用排队等待,避免服务商返回 429;「设置 → 用户管理」中保存的值优先 |
418420
| `TTS_MODE` | 动画语音讲解的朗读方式 | `browser` | `browser` 为浏览器本地语音;`minimax`、`qwen`、`glm`、`stepfun`、`volcengine`、`volcengine-agent`、`openai` 为云端语音,复用对应渠道的 API Key(豆包语音使用 `VOLCENGINE_TTS_API_KEY`,OpenAI 使用当前激活的 OpenAI 配置)。「设置 → AI 设置 → 语音讲解」中保存的值优先 |
419421

420422
**AI 配置**(网页中保存的配置优先于环境变量)

‎doc/wiki/Settings-and-Users.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@
6363
- 0 表示不限制(默认);管理员不受限制。也可以用环境变量 `AI_DAILY_LIMIT` 设置默认值,网页中保存的值优先。
6464
- 次数按应用时区每天零点重置。用完后再点 AI 功能会提示“今天的 AI 使用次数已达上限”。
6565

66+
### 同一模型 AI 并发上限
67+
68+
多人同时识别题目、生成讲解时,短时间内的大量请求容易被服务商以 429 限流拒绝。管理员可在“设置 → 用户管理”中设置“同一模型 AI 并发上限”:
69+
70+
- 同一渠道、接口地址和模型同时进行的 AI 调用不超过上限,超出的调用按先后顺序排队,前面的完成后自动开始;切换到其他模型时各自计数。
71+
- 取值 1~100,默认 5。服务商限流较严(如免费额度、试用套餐)时可调低;排队时间过长且没有 429 报错时可调高。
72+
- 保存后对之后的调用立即生效。也可以用环境变量 `AI_CONCURRENCY_LIMIT` 设置默认值,网页中保存的值优先。
73+
- 排队时间计入页面上的 AI 超时,等待过久的任务可能在页面上显示超时。
74+
6675
全新安装通过一次性初始化链接创建第一个管理员(见[常见问题](FAQ)),不再预置固定密码的默认管理员。已有实例升级保留原账户;`scripts/seed-admin.js` 只为已有的 `admin@localhost` 恢复管理员角色,不会创建账号。
6776

6877
## 后台任务与进度

‎doc/wiki/en/Settings-and-Users-en.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ To keep children from using up your API quota by regenerating explanations and q
6363
- 0 means unlimited (the default); admins are not limited. The environment variable `AI_DAILY_LIMIT` sets the default; a value saved on the web page takes precedence.
6464
- Counts reset at midnight in the application time zone. Once the limit is reached, AI features show "You have used up today's AI allowance".
6565

66+
### AI concurrency per model
67+
68+
When several people recognize questions or generate explanations at once, a burst of requests is easily rejected by the provider with a 429 rate-limit error. Admins can set "AI Concurrency per Model" under "Settings → User Management":
69+
70+
- AI calls to the same provider, API address and model never exceed the limit at the same time. Further calls wait in a queue and start in order as earlier ones finish; different models are counted separately.
71+
- Values range from 1 to 100, default 5. Lower it when the provider limits strictly (free quotas, trial plans); raise it when calls queue for long without any 429 errors.
72+
- A saved value applies to later calls immediately. The environment variable `AI_CONCURRENCY_LIMIT` sets the default; a value saved on the web page takes precedence.
73+
- Time spent in the queue counts toward the AI timeout on the page, so a task that waits too long may show a timeout.
74+
6675
Fresh installs create the first admin with a one-time setup link (see the [FAQ](FAQ-en)) instead of shipping a default admin with a fixed password. Upgrades keep existing accounts; `scripts/seed-admin.js` only restores the admin role of an existing `admin@localhost` and never creates an account.
6776

6877
## Background tasks and progress

‎src/__tests__/integration/settings.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,23 @@ describe('/api/settings', () => {
356356
expect(mocks.mockUpdateAppConfig).not.toHaveBeenCalled();
357357
});
358358

359+
it('保存同一模型 AI 并发上限,拒绝 1~100 以外的值和非整数', async () => {
360+
const post = (aiConcurrencyLimit: unknown) => POST(new Request('http://localhost/api/settings', {
361+
method: 'POST',
362+
body: JSON.stringify({ aiConcurrencyLimit }),
363+
headers: { 'Content-Type': 'application/json' },
364+
}));
365+
366+
expect((await post(8)).status).toBe(200);
367+
expect(mocks.mockUpdateAppConfig).toHaveBeenCalledWith(expect.objectContaining({ aiConcurrencyLimit: 8 }));
368+
369+
mocks.mockUpdateAppConfig.mockClear();
370+
for (const invalid of [0, 101, 2.5, '10']) {
371+
expect((await post(invalid)).status).toBe(400);
372+
}
373+
expect(mocks.mockUpdateAppConfig).not.toHaveBeenCalled();
374+
});
375+
359376
it('MiniMax Code 只保存模型和自动签到开关,拒绝无效取值', async () => {
360377
const post = (minimaxCode: unknown) => POST(new Request('http://localhost/api/settings', {
361378
method: 'POST',
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* AI 并发上限解析单元测试
3+
*/
4+
import { describe, expect, it } from 'vitest';
5+
import { DEFAULT_AI_CONCURRENCY_LIMIT, isValidConcurrencyLimit, parseConcurrencyLimit } from '@/lib/ai-concurrency-limit';
6+
7+
describe('parseConcurrencyLimit', () => {
8+
it.each([
9+
['3', 3],
10+
[10, 10],
11+
[100, 100],
12+
['0', DEFAULT_AI_CONCURRENCY_LIMIT],
13+
[101, DEFAULT_AI_CONCURRENCY_LIMIT],
14+
['2.5', DEFAULT_AI_CONCURRENCY_LIMIT],
15+
['abc', DEFAULT_AI_CONCURRENCY_LIMIT],
16+
['', DEFAULT_AI_CONCURRENCY_LIMIT],
17+
[undefined, DEFAULT_AI_CONCURRENCY_LIMIT],
18+
])('%s → %s', (value, expected) => {
19+
expect(parseConcurrencyLimit(value)).toBe(expected);
20+
});
21+
22+
it('只接受 1~100 的整数', () => {
23+
expect(isValidConcurrencyLimit(1)).toBe(true);
24+
expect(isValidConcurrencyLimit(0)).toBe(false);
25+
expect(isValidConcurrencyLimit('5')).toBe(false);
26+
});
27+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* 同一模型 AI 调用并发限制单元测试
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
const mocks = vi.hoisted(() => ({ config: { aiConcurrencyLimit: 2 } as { aiConcurrencyLimit?: number } }));
7+
vi.mock('@/lib/config', () => ({ getAppConfig: () => mocks.config }));
8+
9+
import { getModelConcurrencyState, limitModelConcurrency, runWithModelConcurrency } from '@/lib/ai/concurrency';
10+
11+
/** 返回一个可手动完成的任务 */
12+
function deferred() {
13+
let resolve!: () => void;
14+
const promise = new Promise<void>((r) => { resolve = r; });
15+
return { promise, resolve };
16+
}
17+
18+
const flush = () => new Promise((r) => setTimeout(r, 0));
19+
20+
describe('runWithModelConcurrency', () => {
21+
beforeEach(() => {
22+
mocks.config = { aiConcurrencyLimit: 2 };
23+
});
24+
25+
it('超过上限的调用排队,名额释放后按顺序执行', async () => {
26+
const tasks = [deferred(), deferred(), deferred()];
27+
const started: number[] = [];
28+
const runs = tasks.map((task, index) => runWithModelConcurrency('m1', async () => {
29+
started.push(index);
30+
await task.promise;
31+
return index;
32+
}));
33+
34+
await flush();
35+
expect(started).toEqual([0, 1]);
36+
expect(getModelConcurrencyState('m1')).toEqual({ active: 2, waiting: 1 });
37+
38+
tasks[0].resolve();
39+
await flush();
40+
expect(started).toEqual([0, 1, 2]);
41+
42+
tasks[1].resolve();
43+
tasks[2].resolve();
44+
expect(await Promise.all(runs)).toEqual([0, 1, 2]);
45+
expect(getModelConcurrencyState('m1')).toEqual({ active: 0, waiting: 0 });
46+
});
47+
48+
it('不同模型各自计数', async () => {
49+
const block = deferred();
50+
const a = [runWithModelConcurrency('a', () => block.promise), runWithModelConcurrency('a', () => block.promise)];
51+
const started = vi.fn();
52+
const b = runWithModelConcurrency('b', async () => started());
53+
54+
await b;
55+
expect(started).toHaveBeenCalled();
56+
block.resolve();
57+
await Promise.all(a);
58+
});
59+
60+
it('调用失败也会释放名额', async () => {
61+
mocks.config = { aiConcurrencyLimit: 1 };
62+
await expect(runWithModelConcurrency('err', async () => { throw new Error('429'); })).rejects.toThrow('429');
63+
expect(await runWithModelConcurrency('err', async () => 'ok')).toBe('ok');
64+
});
65+
66+
it('调高上限后排队的调用随下一次释放一并放行', async () => {
67+
mocks.config = { aiConcurrencyLimit: 1 };
68+
const tasks = [deferred(), deferred(), deferred()];
69+
const started: number[] = [];
70+
const runs = tasks.map((task, index) => runWithModelConcurrency('raise', async () => {
71+
started.push(index);
72+
await task.promise;
73+
}));
74+
await flush();
75+
expect(started).toEqual([0]);
76+
77+
mocks.config = { aiConcurrencyLimit: 3 };
78+
tasks[0].resolve();
79+
await flush();
80+
expect(started).toEqual([0, 1, 2]);
81+
tasks[1].resolve();
82+
tasks[2].resolve();
83+
await Promise.all(runs);
84+
});
85+
86+
it('配置无效时使用默认上限 5', async () => {
87+
mocks.config = { aiConcurrencyLimit: 0 };
88+
const block = deferred();
89+
const started: number[] = [];
90+
const runs = Array.from({ length: 6 }, (_, index) => runWithModelConcurrency('default', async () => {
91+
started.push(index);
92+
await block.promise;
93+
}));
94+
await flush();
95+
expect(started).toHaveLength(5);
96+
block.resolve();
97+
await Promise.all(runs);
98+
});
99+
});
100+
101+
describe('limitModelConcurrency', () => {
102+
it('包装后的方法受并发限制,内部互相调用不重复排队', async () => {
103+
mocks.config = { aiConcurrencyLimit: 1 };
104+
const service = {
105+
async outer() { return this.inner(); },
106+
async inner() { return 'done'; },
107+
};
108+
const limited = limitModelConcurrency(service, 'svc');
109+
110+
expect(await limited.outer()).toBe('done');
111+
expect(getModelConcurrencyState('svc')).toEqual({ active: 0, waiting: 0 });
112+
});
113+
});

‎src/__tests__/unit/config.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ describe('config module', () => {
7777
expect(getAppConfig().aiDailyLimit).toBe(expected);
7878
});
7979

80+
it.each([
81+
['8', 8],
82+
['0', 5],
83+
['abc', 5],
84+
[undefined, 5],
85+
])('AI_CONCURRENCY_LIMIT=%s 时默认并发上限为 %s', async (value, expected) => {
86+
if (value === undefined) delete process.env.AI_CONCURRENCY_LIMIT;
87+
else process.env.AI_CONCURRENCY_LIMIT = value;
88+
mockNoConfigFile();
89+
90+
const { getAppConfig } = await import('@/lib/config');
91+
92+
expect(getAppConfig().aiConcurrencyLimit).toBe(expected);
93+
});
94+
8095
it('配置文件中保存的注册开关优先于环境变量', async () => {
8196
process.env.ALLOW_REGISTRATION = 'false';
8297
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ allowRegistration: true }));

‎src/app/api/settings/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from "zod";
44
import { authOptions } from "@/lib/auth";
55
import { requireAdmin } from "@/lib/auth-utils";
66
import { getAppConfig, updateAppConfig } from "@/lib/config";
7+
import { isValidConcurrencyLimit } from "@/lib/ai-concurrency-limit";
78
import { badRequest, forbidden, internalError, unauthorized } from "@/lib/api-errors";
89
import { createLogger } from "@/lib/logger";
910
import type { OpenAIInstance } from "@/types/api";
@@ -64,6 +65,9 @@ export async function POST(req: Request) {
6465
if (body.aiDailyLimit !== undefined && !(Number.isInteger(body.aiDailyLimit) && body.aiDailyLimit >= 0)) {
6566
return badRequest('INVALID_AI_DAILY_LIMIT');
6667
}
68+
if (body.aiConcurrencyLimit !== undefined && !isValidConcurrencyLimit(body.aiConcurrencyLimit)) {
69+
return badRequest('INVALID_AI_CONCURRENCY_LIMIT');
70+
}
6771
if (body.minimaxCode !== undefined) {
6872
const parsed = minimaxCodeSchema.safeParse(body.minimaxCode);
6973
if (!parsed.success) return badRequest('INVALID_MINIMAX_CODE_CONFIG');

0 commit comments

Comments
 (0)