sdpy-kit 0.6.4__tar.gz → 0.6.5__tar.gz
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.
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/PKG-INFO +31 -4
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/README.md +30 -3
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/__init__.py +1 -1
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/__init__.py +2 -1
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/base_service.py +15 -2
- sdpy_kit-0.6.5/kit/ai/chat_service.py +266 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/failover.py +66 -19
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/profiles.py +9 -1
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/runner.py +48 -3
- sdpy_kit-0.6.4/kit/ai/chat_service.py +0 -160
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/.gitignore +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/pool.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/ai/types.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/common_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/audit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/crud.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/engine.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/query.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/session.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/file_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/license_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.6.4 → sdpy_kit-0.6.5}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sdpy-kit
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.5
|
|
4
4
|
Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
|
|
5
5
|
Author-email: Clark <changhongyuan@126.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -785,7 +785,7 @@ shutdown() # 应用退出前
|
|
|
785
785
|
|------|------|------|
|
|
786
786
|
| chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
|
|
787
787
|
| chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
|
|
788
|
-
| chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with`
|
|
788
|
+
| chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
|
|
789
789
|
| embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
|
|
790
790
|
| embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
|
|
791
791
|
| rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
|
|
@@ -802,6 +802,32 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
|
|
|
802
802
|
print(token, end="", flush=True)
|
|
803
803
|
```
|
|
804
804
|
|
|
805
|
+
#### 流式收尾统计(思考型模型必备)
|
|
806
|
+
|
|
807
|
+
迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
|
|
808
|
+
`truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
|
|
809
|
+
token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
|
|
810
|
+
|
|
811
|
+
```python
|
|
812
|
+
with AI.chat_stream(messages, max_tokens=450) as stream:
|
|
813
|
+
body = "".join(stream)
|
|
814
|
+
if not body and stream.truncated and stream.reasoning_chars:
|
|
815
|
+
raise ValueError(
|
|
816
|
+
f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
|
|
817
|
+
"请调大 max_tokens 或改用关闭思考的档案"
|
|
818
|
+
)
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
- 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
|
|
822
|
+
- 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
|
|
823
|
+
- `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
|
|
824
|
+
- **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
|
|
825
|
+
- provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
|
|
826
|
+
- 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
|
|
827
|
+
|
|
828
|
+
> 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
|
|
829
|
+
> 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
|
|
830
|
+
|
|
805
831
|
> 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
|
|
806
832
|
> 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
|
|
807
833
|
|
|
@@ -811,7 +837,7 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
|
|
|
811
837
|
from kit.ai import (
|
|
812
838
|
ChatService, EmbeddingService, RerankService, ImageService, # 服务类
|
|
813
839
|
ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest, # 请求模型
|
|
814
|
-
RerankHit, ModelBuilder,
|
|
840
|
+
RerankHit, StreamStats, ModelBuilder, # 结果类型 / 构建器
|
|
815
841
|
)
|
|
816
842
|
|
|
817
843
|
service = ModelBuilder.build_chat() # 老按类型构建:build_chat / build_embedding / build_rerank / build_image
|
|
@@ -821,7 +847,7 @@ await service.close()
|
|
|
821
847
|
|
|
822
848
|
| 类 | 方法 |
|
|
823
849
|
|----|------|
|
|
824
|
-
| `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *,
|
|
850
|
+
| `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
|
|
825
851
|
| `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
|
|
826
852
|
| `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
|
|
827
853
|
| `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
|
|
@@ -1354,6 +1380,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
|
|
|
1354
1380
|
| 档案级 | `timeout` | 请求超时秒数(默认 60) |
|
|
1355
1381
|
| 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
|
|
1356
1382
|
| 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
|
|
1383
|
+
| 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
|
|
1357
1384
|
| 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
|
|
1358
1385
|
|
|
1359
1386
|
### 候选链切换语义(两类,勿混淆)
|
|
@@ -709,7 +709,7 @@ shutdown() # 应用退出前
|
|
|
709
709
|
|------|------|------|
|
|
710
710
|
| chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
|
|
711
711
|
| chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
|
|
712
|
-
| chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with`
|
|
712
|
+
| chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
|
|
713
713
|
| embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
|
|
714
714
|
| embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
|
|
715
715
|
| rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
|
|
@@ -726,6 +726,32 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
|
|
|
726
726
|
print(token, end="", flush=True)
|
|
727
727
|
```
|
|
728
728
|
|
|
729
|
+
#### 流式收尾统计(思考型模型必备)
|
|
730
|
+
|
|
731
|
+
迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
|
|
732
|
+
`truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
|
|
733
|
+
token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
|
|
734
|
+
|
|
735
|
+
```python
|
|
736
|
+
with AI.chat_stream(messages, max_tokens=450) as stream:
|
|
737
|
+
body = "".join(stream)
|
|
738
|
+
if not body and stream.truncated and stream.reasoning_chars:
|
|
739
|
+
raise ValueError(
|
|
740
|
+
f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
|
|
741
|
+
"请调大 max_tokens 或改用关闭思考的档案"
|
|
742
|
+
)
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
- 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
|
|
746
|
+
- 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
|
|
747
|
+
- `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
|
|
748
|
+
- **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
|
|
749
|
+
- provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
|
|
750
|
+
- 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
|
|
751
|
+
|
|
752
|
+
> 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
|
|
753
|
+
> 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
|
|
754
|
+
|
|
729
755
|
> 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
|
|
730
756
|
> 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
|
|
731
757
|
|
|
@@ -735,7 +761,7 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
|
|
|
735
761
|
from kit.ai import (
|
|
736
762
|
ChatService, EmbeddingService, RerankService, ImageService, # 服务类
|
|
737
763
|
ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest, # 请求模型
|
|
738
|
-
RerankHit, ModelBuilder,
|
|
764
|
+
RerankHit, StreamStats, ModelBuilder, # 结果类型 / 构建器
|
|
739
765
|
)
|
|
740
766
|
|
|
741
767
|
service = ModelBuilder.build_chat() # 老按类型构建:build_chat / build_embedding / build_rerank / build_image
|
|
@@ -745,7 +771,7 @@ await service.close()
|
|
|
745
771
|
|
|
746
772
|
| 类 | 方法 |
|
|
747
773
|
|----|------|
|
|
748
|
-
| `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *,
|
|
774
|
+
| `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
|
|
749
775
|
| `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
|
|
750
776
|
| `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
|
|
751
777
|
| `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
|
|
@@ -1278,6 +1304,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
|
|
|
1278
1304
|
| 档案级 | `timeout` | 请求超时秒数(默认 60) |
|
|
1279
1305
|
| 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
|
|
1280
1306
|
| 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
|
|
1307
|
+
| 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
|
|
1281
1308
|
| 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
|
|
1282
1309
|
|
|
1283
1310
|
### 候选链切换语义(两类,勿混淆)
|
|
@@ -27,7 +27,7 @@ Author: Clark Chang
|
|
|
27
27
|
- ImageService: 文生图
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
|
-
from kit.ai.chat_service import ChatRequest, ChatService
|
|
30
|
+
from kit.ai.chat_service import ChatRequest, ChatService, StreamStats
|
|
31
31
|
from kit.ai.embedding_service import EmbeddingRequest, EmbeddingService
|
|
32
32
|
from kit.ai.image_service import ImageRequest, ImageService
|
|
33
33
|
from kit.ai.model_builder import ModelBuilder
|
|
@@ -44,4 +44,5 @@ __all__ = [
|
|
|
44
44
|
"RerankHit",
|
|
45
45
|
"RerankRequest",
|
|
46
46
|
"RerankService",
|
|
47
|
+
"StreamStats",
|
|
47
48
|
]
|
|
@@ -20,7 +20,7 @@ Author: Clark Chang
|
|
|
20
20
|
import json
|
|
21
21
|
import time
|
|
22
22
|
from abc import ABC, abstractmethod
|
|
23
|
-
from typing import ClassVar, Final, Self, override
|
|
23
|
+
from typing import ClassVar, Final, Self, cast, override
|
|
24
24
|
|
|
25
25
|
from kit.ai.exceptions import AIError, AIResponseError
|
|
26
26
|
from kit.ai.model_client import ModelClient
|
|
@@ -109,6 +109,19 @@ class BaseAIService[RequestT, ResponseT](ABC):
|
|
|
109
109
|
def _build_payload(self, request: RequestT) -> JSONObject:
|
|
110
110
|
"""将请求模型构造为 JSON 请求体。"""
|
|
111
111
|
|
|
112
|
+
def _apply_extra_body(self, payload: JSONObject) -> JSONObject:
|
|
113
|
+
"""合并档案级 ``extra_body``(厂商专有请求字段透传)。
|
|
114
|
+
|
|
115
|
+
同名键以服务构造的值为准——``model`` / ``messages`` / ``stream`` /
|
|
116
|
+
``input`` / ``prompt`` 等保留键**不可被档案覆盖**,故无需白名单;
|
|
117
|
+
未配置时原样返回(请求体逐字节不变)。
|
|
118
|
+
"""
|
|
119
|
+
extra = self._profile.extra_body
|
|
120
|
+
if not extra:
|
|
121
|
+
return payload
|
|
122
|
+
merged: dict[str, object] = {**extra, **payload}
|
|
123
|
+
return cast(JSONObject, merged)
|
|
124
|
+
|
|
112
125
|
@abstractmethod
|
|
113
126
|
def _parse_response(self, data: JSONObject) -> ResponseT:
|
|
114
127
|
"""将 JSON 响应体解析为业务结果。"""
|
|
@@ -124,7 +137,7 @@ class BaseAIService[RequestT, ResponseT](ABC):
|
|
|
124
137
|
``post_json`` 的重试预算检查;None 表示不限制
|
|
125
138
|
"""
|
|
126
139
|
desc = self._log_desc()
|
|
127
|
-
payload = self._build_payload(request)
|
|
140
|
+
payload = self._apply_extra_body(self._build_payload(request))
|
|
128
141
|
params = _truncate(
|
|
129
142
|
json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
|
|
130
143
|
)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
|
|
16
|
+
Project: sdpy
|
|
17
|
+
Author: Clark Chang
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import ClassVar, Final, cast, override
|
|
25
|
+
|
|
26
|
+
from kit.ai.base_service import (
|
|
27
|
+
_LOG_PAYLOAD_LIMIT,
|
|
28
|
+
BaseAIService,
|
|
29
|
+
_clip,
|
|
30
|
+
_truncate,
|
|
31
|
+
safe_get,
|
|
32
|
+
)
|
|
33
|
+
from kit.ai.exceptions import AIError, AIResponseError
|
|
34
|
+
from kit.ai.types import JSONObject
|
|
35
|
+
from kit.config.log_config import get_default_logger
|
|
36
|
+
|
|
37
|
+
__all__ = ["ChatRequest", "ChatService", "StreamStats"]
|
|
38
|
+
|
|
39
|
+
logger = get_default_logger(__name__)
|
|
40
|
+
|
|
41
|
+
Message = dict[str, str]
|
|
42
|
+
|
|
43
|
+
# 推理(思考)内容可能的字段名:GLM / DeepSeek / Qwen 用 reasoning_content,
|
|
44
|
+
# sensenova 用 reasoning(流式为 delta.*,非流式为 message.*)
|
|
45
|
+
_REASONING_KEYS: Final[tuple[str, ...]] = ("reasoning_content", "reasoning")
|
|
46
|
+
|
|
47
|
+
# 截断判定:OpenAI 系给 length,部分兼容端点给 max_tokens
|
|
48
|
+
_TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class StreamStats:
|
|
53
|
+
"""对话收尾统计(可变累加器:调用方传入,kit 累加)。
|
|
54
|
+
|
|
55
|
+
跨线程共享(后台事件循环写、业务线程读):**流结束后为最终值**,
|
|
56
|
+
流式过程中读到的是尽力而为的中间值。
|
|
57
|
+
|
|
58
|
+
``reasoning_chars`` / ``content_chars`` 均为**字符数**,与 ``max_tokens``
|
|
59
|
+
(token)不可换算(实测中文约 2~2.5 字符/token,且各 provider 口径不一),
|
|
60
|
+
仅用于「是否发生思考 / 是否被长度截断」的判定。
|
|
61
|
+
|
|
62
|
+
Attributes:
|
|
63
|
+
reasoning_chars: 推理(思考)内容字符数;非思考模型恒为 0
|
|
64
|
+
content_chars: 已产出正文字符数(等于调用方收到的字符数)
|
|
65
|
+
finish_reason: 结束原因(stop / length / content_filter / …);
|
|
66
|
+
provider 未给出时为 None
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
reasoning_chars: int = 0
|
|
70
|
+
content_chars: int = 0
|
|
71
|
+
finish_reason: str | None = None
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def truncated(self) -> bool:
|
|
75
|
+
"""是否因输出长度上限被截断。"""
|
|
76
|
+
return self.finish_reason in _TRUNCATED_REASONS
|
|
77
|
+
|
|
78
|
+
def reset(self) -> None:
|
|
79
|
+
"""清零(候选链切换到下一个档案前调用)。"""
|
|
80
|
+
self.reasoning_chars = 0
|
|
81
|
+
self.content_chars = 0
|
|
82
|
+
self.finish_reason = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ChatRequest:
|
|
87
|
+
"""对话请求模型。"""
|
|
88
|
+
|
|
89
|
+
messages: list[Message]
|
|
90
|
+
temperature: float | None = None
|
|
91
|
+
max_tokens: int | None = None
|
|
92
|
+
stream: bool = False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ChatService(BaseAIService[ChatRequest, str]):
|
|
96
|
+
"""对话类模型服务(全异步)。"""
|
|
97
|
+
|
|
98
|
+
_LABEL: ClassVar[str] = "对话模型"
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
def _build_payload(self, request: ChatRequest) -> JSONObject:
|
|
102
|
+
payload: dict[str, object] = {
|
|
103
|
+
"model": self._profile.model,
|
|
104
|
+
"messages": request.messages,
|
|
105
|
+
"stream": request.stream,
|
|
106
|
+
}
|
|
107
|
+
if request.temperature is not None:
|
|
108
|
+
payload["temperature"] = request.temperature
|
|
109
|
+
# max_tokens 逐级回落:调用点显式传入 → 档案配置 → 不写(provider 默认)
|
|
110
|
+
max_tokens = (
|
|
111
|
+
request.max_tokens
|
|
112
|
+
if request.max_tokens is not None
|
|
113
|
+
else self._profile.max_tokens
|
|
114
|
+
)
|
|
115
|
+
if max_tokens is not None:
|
|
116
|
+
payload["max_tokens"] = max_tokens
|
|
117
|
+
return cast(JSONObject, payload)
|
|
118
|
+
|
|
119
|
+
@override
|
|
120
|
+
def _parse_response(self, data: JSONObject) -> str:
|
|
121
|
+
choice = safe_get(data, "choices", 0)
|
|
122
|
+
if not isinstance(choice, dict):
|
|
123
|
+
raise AIResponseError(f"choices[0] 类型异常: {type(choice).__name__}")
|
|
124
|
+
message = choice.get("message")
|
|
125
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
126
|
+
finish = choice.get("finish_reason")
|
|
127
|
+
reasoning = 0
|
|
128
|
+
if isinstance(message, dict):
|
|
129
|
+
for key in _REASONING_KEYS:
|
|
130
|
+
part = message.get(key)
|
|
131
|
+
if isinstance(part, str) and part:
|
|
132
|
+
reasoning = len(part)
|
|
133
|
+
break
|
|
134
|
+
if not isinstance(content, str):
|
|
135
|
+
if finish in _TRUNCATED_REASONS and reasoning:
|
|
136
|
+
# 思考型模型截断时可能完全不返回 content 键(实测 sensenova)
|
|
137
|
+
raise AIResponseError(
|
|
138
|
+
f"输出预算被思考耗尽:finish_reason={finish},"
|
|
139
|
+
f"推理 {reasoning} 字符、正文为空"
|
|
140
|
+
"(请调大 max_tokens 或在档案 extra_body 中关闭思考)"
|
|
141
|
+
)
|
|
142
|
+
raise AIResponseError(f"content 类型异常: {type(content).__name__}")
|
|
143
|
+
if finish in _TRUNCATED_REASONS and (reasoning or not content):
|
|
144
|
+
logger.warning(
|
|
145
|
+
f"{self._log_desc()} 输出被长度截断(finish_reason={finish}):"
|
|
146
|
+
f"推理 {reasoning} 字符 / 正文 {len(content)} 字符"
|
|
147
|
+
"(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
|
|
148
|
+
)
|
|
149
|
+
return content
|
|
150
|
+
|
|
151
|
+
async def ask(
|
|
152
|
+
self,
|
|
153
|
+
messages: list[Message],
|
|
154
|
+
*,
|
|
155
|
+
temperature: float | None = None,
|
|
156
|
+
max_tokens: int | None = None,
|
|
157
|
+
deadline: float | None = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""多轮对话,返回模型回答文本。
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
|
|
163
|
+
5xx 重试预算;None 表示不限制
|
|
164
|
+
"""
|
|
165
|
+
request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
|
|
166
|
+
return await self._call(request, deadline=deadline)
|
|
167
|
+
|
|
168
|
+
async def complete(
|
|
169
|
+
self,
|
|
170
|
+
prompt: str,
|
|
171
|
+
*,
|
|
172
|
+
temperature: float | None = None,
|
|
173
|
+
max_tokens: int | None = None,
|
|
174
|
+
deadline: float | None = None,
|
|
175
|
+
) -> str:
|
|
176
|
+
messages: list[Message] = [{"role": "user", "content": prompt}]
|
|
177
|
+
return await self.ask(
|
|
178
|
+
messages, temperature=temperature, max_tokens=max_tokens, deadline=deadline
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
async def stream(
|
|
182
|
+
self,
|
|
183
|
+
messages: list[Message],
|
|
184
|
+
*,
|
|
185
|
+
temperature: float | None = None,
|
|
186
|
+
max_tokens: int | None = None,
|
|
187
|
+
stats: StreamStats | None = None,
|
|
188
|
+
) -> AsyncIterator[str]:
|
|
189
|
+
"""流式对话,逐 token 产出正文。
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
stats: 收尾统计累加器(可选);None 时内部创建后丢弃——需要
|
|
193
|
+
``reasoning_chars`` / ``finish_reason`` / ``truncated`` 时
|
|
194
|
+
由调用方传入一个实例
|
|
195
|
+
|
|
196
|
+
推理内容(``delta.reasoning_content`` / ``delta.reasoning``)只计数
|
|
197
|
+
不产出;正文为空且被截断(或发生过思考)时输出 warning。
|
|
198
|
+
"""
|
|
199
|
+
request = ChatRequest(
|
|
200
|
+
messages, temperature=temperature, max_tokens=max_tokens, stream=True
|
|
201
|
+
)
|
|
202
|
+
payload = self._apply_extra_body(self._build_payload(request))
|
|
203
|
+
desc = self._log_desc()
|
|
204
|
+
params = _truncate(
|
|
205
|
+
json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
|
|
206
|
+
)
|
|
207
|
+
stats = stats if stats is not None else StreamStats()
|
|
208
|
+
# 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
|
|
209
|
+
logger.info(f"********** {desc} ********** Start")
|
|
210
|
+
logger.info(f"请求参数: {params}")
|
|
211
|
+
start = time.perf_counter()
|
|
212
|
+
note = ""
|
|
213
|
+
failed = False
|
|
214
|
+
try:
|
|
215
|
+
async for parsed in self._client.stream_json(payload):
|
|
216
|
+
if parsed is None:
|
|
217
|
+
continue
|
|
218
|
+
choices = parsed.get("choices")
|
|
219
|
+
if not isinstance(choices, list) or not choices:
|
|
220
|
+
continue
|
|
221
|
+
choice = choices[0]
|
|
222
|
+
if not isinstance(choice, dict):
|
|
223
|
+
continue
|
|
224
|
+
finish = choice.get("finish_reason")
|
|
225
|
+
if isinstance(finish, str) and finish:
|
|
226
|
+
stats.finish_reason = finish
|
|
227
|
+
delta = choice.get("delta")
|
|
228
|
+
if not isinstance(delta, dict):
|
|
229
|
+
continue
|
|
230
|
+
for key in _REASONING_KEYS:
|
|
231
|
+
part = delta.get(key)
|
|
232
|
+
if isinstance(part, str) and part:
|
|
233
|
+
stats.reasoning_chars += len(part)
|
|
234
|
+
break
|
|
235
|
+
content = delta.get("content")
|
|
236
|
+
if isinstance(content, str) and content:
|
|
237
|
+
stats.content_chars += len(content)
|
|
238
|
+
yield content
|
|
239
|
+
except AIError as exc:
|
|
240
|
+
note = f"(失败:{exc})"
|
|
241
|
+
failed = True
|
|
242
|
+
raise
|
|
243
|
+
except BaseException: # noqa: BLE001 - 中断路径(提前 break/取消)也需收尾日志
|
|
244
|
+
note = "(中断)"
|
|
245
|
+
raise
|
|
246
|
+
else:
|
|
247
|
+
self._warn_if_budget_eaten(stats, payload.get("max_tokens"))
|
|
248
|
+
finally:
|
|
249
|
+
log = logger.error if failed else logger.info
|
|
250
|
+
log(
|
|
251
|
+
f"********** {desc} ********** "
|
|
252
|
+
f"耗时 {time.perf_counter() - start:.1f}秒{note}"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def _warn_if_budget_eaten(
|
|
256
|
+
self, stats: StreamStats, max_tokens: object
|
|
257
|
+
) -> None:
|
|
258
|
+
"""正文为空且(被截断或有推理)时告警:讲清「预算被思考吃掉」。"""
|
|
259
|
+
if stats.content_chars or not (stats.truncated or stats.reasoning_chars):
|
|
260
|
+
return
|
|
261
|
+
logger.warning(
|
|
262
|
+
f"{self._log_desc()} 输出预算疑被思考耗尽:max_tokens={max_tokens},"
|
|
263
|
+
f"finish_reason={stats.finish_reason},"
|
|
264
|
+
f"推理 {stats.reasoning_chars} 字符 / 正文 0 字符"
|
|
265
|
+
"(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
|
|
266
|
+
)
|
|
@@ -35,9 +35,9 @@ Author: Clark Chang
|
|
|
35
35
|
import asyncio
|
|
36
36
|
import time
|
|
37
37
|
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
|
38
|
-
from typing import TypeVar, cast
|
|
38
|
+
from typing import Final, TypeVar, cast
|
|
39
39
|
|
|
40
|
-
from kit.ai.chat_service import ChatService
|
|
40
|
+
from kit.ai.chat_service import ChatService, StreamStats
|
|
41
41
|
from kit.ai.embedding_service import EmbeddingService
|
|
42
42
|
from kit.ai.exceptions import AIAggregateError, AIError, AIRequestError, AITimeoutError
|
|
43
43
|
from kit.ai.image_service import ImageService
|
|
@@ -57,6 +57,10 @@ T = TypeVar("T")
|
|
|
57
57
|
# 批量向量化默认并发(与 EmbeddingService 保持一致)
|
|
58
58
|
_EMBED_CONCURRENCY = 4
|
|
59
59
|
|
|
60
|
+
# 流式首帧等待的轮询粒度(秒):只用于「有无任何帧」的续期判定,
|
|
61
|
+
# 不改变超时时长语义(推理帧被吞掉,不轮询就看不到思考阶段的活动)
|
|
62
|
+
_FIRST_FRAME_POLL: Final[float] = 1.0
|
|
63
|
+
|
|
60
64
|
|
|
61
65
|
class _FailoverBase:
|
|
62
66
|
"""链式调用基类:候选迭代 + 失败冷却 + 整链预算 + 聚合异常。"""
|
|
@@ -202,48 +206,44 @@ class FailoverChatService(_FailoverBase):
|
|
|
202
206
|
max_tokens: int | None = None,
|
|
203
207
|
first_frame_timeout: float | None = None,
|
|
204
208
|
deadline: float | None = None,
|
|
209
|
+
stats: StreamStats | None = None,
|
|
205
210
|
) -> AsyncIterator[str]:
|
|
206
211
|
"""流式对话:首帧前失败自动换档案重试;首帧后失败直接抛出。
|
|
207
212
|
|
|
208
213
|
Args:
|
|
209
|
-
first_frame_timeout:
|
|
210
|
-
|
|
214
|
+
first_frame_timeout: 单候选首帧等待上限(秒);窗口内**一帧都没有**
|
|
215
|
+
(含推理帧)才按失败处理切下一候选;None 不限制
|
|
211
216
|
deadline: 整链预算截止时刻;每个候选发起前检查
|
|
217
|
+
stats: 收尾统计累加器;候选切换前清零,故只反映最终成功的候选
|
|
212
218
|
"""
|
|
213
219
|
tried: list[str] = []
|
|
214
220
|
errors: list[AIError] = []
|
|
215
221
|
budget_exhausted = False
|
|
222
|
+
stats = stats if stats is not None else StreamStats()
|
|
216
223
|
for name, service in self._pool.iter_services(self._model_type):
|
|
217
224
|
if self._budget_exhausted(deadline):
|
|
218
225
|
budget_exhausted = True
|
|
219
226
|
break
|
|
220
227
|
tried.append(name)
|
|
228
|
+
stats.reset()
|
|
221
229
|
agen = cast(
|
|
222
230
|
AsyncGenerator[str, None],
|
|
223
231
|
cast(ChatService, service).stream(
|
|
224
|
-
messages,
|
|
232
|
+
messages,
|
|
233
|
+
temperature=temperature,
|
|
234
|
+
max_tokens=max_tokens,
|
|
235
|
+
stats=stats,
|
|
225
236
|
),
|
|
226
237
|
)
|
|
227
238
|
try:
|
|
228
239
|
try:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
)
|
|
233
|
-
else:
|
|
234
|
-
first = await agen.__anext__()
|
|
240
|
+
first = await self._await_first_frame(
|
|
241
|
+
agen, stats, first_frame_timeout
|
|
242
|
+
)
|
|
235
243
|
except StopAsyncIteration:
|
|
236
244
|
# 空流(首帧即结束)视为正常完成
|
|
237
245
|
await agen.aclose()
|
|
238
246
|
return
|
|
239
|
-
except asyncio.TimeoutError:
|
|
240
|
-
timeout_exc = AITimeoutError(
|
|
241
|
-
f"流式首帧超过 {first_frame_timeout:g}s 未到达"
|
|
242
|
-
)
|
|
243
|
-
errors.append(timeout_exc)
|
|
244
|
-
self._pool.mark_failure(self._model_type, name, timeout_exc)
|
|
245
|
-
await agen.aclose()
|
|
246
|
-
continue
|
|
247
247
|
except AIError as exc:
|
|
248
248
|
errors.append(exc)
|
|
249
249
|
self._pool.mark_failure(
|
|
@@ -270,6 +270,53 @@ class FailoverChatService(_FailoverBase):
|
|
|
270
270
|
"流式对话", tried, errors, budget_exhausted=budget_exhausted
|
|
271
271
|
)
|
|
272
272
|
|
|
273
|
+
async def _await_first_frame(
|
|
274
|
+
self,
|
|
275
|
+
agen: AsyncGenerator[str, None],
|
|
276
|
+
stats: StreamStats,
|
|
277
|
+
timeout: float | None,
|
|
278
|
+
) -> str:
|
|
279
|
+
"""等首个正文帧;期间任何帧(含推理帧)都算活动并重置窗口。
|
|
280
|
+
|
|
281
|
+
``ChatService.stream`` 只产出正文、推理帧仅计数,故「首个 yield 到达」
|
|
282
|
+
会把整个思考阶段表现为静默——按窗口计时会误判健康的思考型模型失败
|
|
283
|
+
(并重放整段 prompt)。改为按 ``stats.reasoning_chars`` 的推进续期。
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
AITimeoutError: 窗口内一帧都没有
|
|
287
|
+
StopAsyncIteration: 空流(首帧即结束)
|
|
288
|
+
"""
|
|
289
|
+
if timeout is None:
|
|
290
|
+
return await agen.__anext__()
|
|
291
|
+
task = asyncio.ensure_future(agen.__anext__())
|
|
292
|
+
waited = 0.0
|
|
293
|
+
seen = stats.reasoning_chars
|
|
294
|
+
try:
|
|
295
|
+
while True:
|
|
296
|
+
remaining = timeout - waited
|
|
297
|
+
if remaining <= 0:
|
|
298
|
+
raise AITimeoutError(f"流式首帧超过 {timeout:g}s 未到达")
|
|
299
|
+
step = min(_FIRST_FRAME_POLL, remaining)
|
|
300
|
+
done, _ = await asyncio.wait({task}, timeout=step)
|
|
301
|
+
if task in done:
|
|
302
|
+
return task.result()
|
|
303
|
+
if stats.reasoning_chars != seen:
|
|
304
|
+
# 推理帧在推进:思考阶段属于正常活动,窗口续期
|
|
305
|
+
seen = stats.reasoning_chars
|
|
306
|
+
waited = 0.0
|
|
307
|
+
continue
|
|
308
|
+
waited += step
|
|
309
|
+
except BaseException:
|
|
310
|
+
if not task.done():
|
|
311
|
+
task.cancel()
|
|
312
|
+
# 等 __anext__ 真正退栈,否则调用方随后 aclose() 会撞
|
|
313
|
+
# 「asynchronous generator is already running」
|
|
314
|
+
try:
|
|
315
|
+
await task
|
|
316
|
+
except BaseException: # noqa: BLE001 - 取消后的收尾无需上抛
|
|
317
|
+
pass
|
|
318
|
+
raise
|
|
319
|
+
|
|
273
320
|
|
|
274
321
|
class FailoverEmbeddingService(_FailoverBase):
|
|
275
322
|
"""向量化服务链式调用(embed_text / embed_texts)。"""
|
|
@@ -34,7 +34,7 @@ import os
|
|
|
34
34
|
from dataclasses import dataclass, replace
|
|
35
35
|
from functools import lru_cache
|
|
36
36
|
from pathlib import Path
|
|
37
|
-
from typing import Final
|
|
37
|
+
from typing import Any, Final
|
|
38
38
|
|
|
39
39
|
from dotenv import load_dotenv
|
|
40
40
|
from pydantic import BaseModel, Field, ValidationError
|
|
@@ -75,6 +75,9 @@ class ModelProfile:
|
|
|
75
75
|
保证输出恒等于该值:模型原生维度更大时截断(含 L2 归一化),
|
|
76
76
|
更小时报错;建表与维度守卫均应以它为唯一来源
|
|
77
77
|
batch_size: 单次请求最大文本数(仅 embedding 有效)
|
|
78
|
+
extra_body: 档案级额外请求体字段(厂商专有参数透传,如 GLM/Qwen 的
|
|
79
|
+
``{"thinking": {"type": "disabled"}}``);与服务构造的同名键
|
|
80
|
+
冲突时**以服务的值为准**(不可覆盖 model / messages / stream 等保留键)
|
|
78
81
|
"""
|
|
79
82
|
|
|
80
83
|
name: str
|
|
@@ -89,6 +92,7 @@ class ModelProfile:
|
|
|
89
92
|
retries: int = 2
|
|
90
93
|
dimension: int | None = None
|
|
91
94
|
batch_size: int = 32
|
|
95
|
+
extra_body: dict[str, Any] | None = None
|
|
92
96
|
|
|
93
97
|
@property
|
|
94
98
|
def url(self) -> str:
|
|
@@ -144,6 +148,9 @@ class ProfileConfig(BaseModel):
|
|
|
144
148
|
retries: int = Field(default=2, ge=0)
|
|
145
149
|
dimension: int | None = Field(default=None, gt=0)
|
|
146
150
|
batch_size: int = Field(default=32, gt=0)
|
|
151
|
+
# 厂商专有请求字段透传(如 {"thinking": {"type": "disabled"}});
|
|
152
|
+
# 与服务构造的同名键冲突时以服务的值为准,故保留键无法被覆盖
|
|
153
|
+
extra_body: dict[str, Any] | None = None
|
|
147
154
|
|
|
148
155
|
|
|
149
156
|
class TypeConfig(BaseModel):
|
|
@@ -277,6 +284,7 @@ class ModelRegistry:
|
|
|
277
284
|
retries=config.retries,
|
|
278
285
|
dimension=config.dimension,
|
|
279
286
|
batch_size=config.batch_size,
|
|
287
|
+
extra_body=config.extra_body,
|
|
280
288
|
)
|
|
281
289
|
|
|
282
290
|
def get_profile(self, model_type: str, profile: str | None = None) -> ProfileConfig:
|
|
@@ -70,6 +70,7 @@ from collections.abc import Callable, Coroutine, Iterable, Iterator
|
|
|
70
70
|
from dataclasses import dataclass
|
|
71
71
|
from typing import Any, Final, TypeVar, cast
|
|
72
72
|
|
|
73
|
+
from kit.ai.chat_service import StreamStats
|
|
73
74
|
from kit.ai.exceptions import AIConfigError, AITimeoutError
|
|
74
75
|
from kit.ai.failover import (
|
|
75
76
|
FailoverChatService,
|
|
@@ -318,9 +319,13 @@ class _BackgroundLoop:
|
|
|
318
319
|
q: queue.Queue[object],
|
|
319
320
|
*,
|
|
320
321
|
max_tokens: int | None = None,
|
|
322
|
+
stats: StreamStats,
|
|
321
323
|
) -> concurrent.futures.Future[None]:
|
|
322
324
|
"""投递流式收集任务:后台拉取链式 ``stream`` 帧写入线程安全队列。
|
|
323
325
|
|
|
326
|
+
Args:
|
|
327
|
+
stats: 收尾统计累加器(由同步侧持有并读取)
|
|
328
|
+
|
|
324
329
|
返回的 future 可取消,用于同步侧中断时停止后台拉取。
|
|
325
330
|
"""
|
|
326
331
|
loop = self._ensure_started()
|
|
@@ -334,6 +339,7 @@ class _BackgroundLoop:
|
|
|
334
339
|
messages,
|
|
335
340
|
max_tokens=max_tokens,
|
|
336
341
|
first_frame_timeout=_STREAM_FIRST_FRAME_TIMEOUT,
|
|
342
|
+
stats=stats,
|
|
337
343
|
):
|
|
338
344
|
q.put(delta)
|
|
339
345
|
except BaseException as exc: # noqa: BLE001 - 异常经队列回传给同步侧
|
|
@@ -666,8 +672,9 @@ class AI:
|
|
|
666
672
|
|
|
667
673
|
在同步线程内逐帧消费,适合流式输出;内部在后台 loop 持续拉取
|
|
668
674
|
并放入线程安全队列。超时保护:
|
|
669
|
-
- 首帧前:每候选 ``_STREAM_FIRST_FRAME_TIMEOUT``(60s
|
|
670
|
-
|
|
675
|
+
- 首帧前:每候选 ``_STREAM_FIRST_FRAME_TIMEOUT``(60s)内**一帧
|
|
676
|
+
都没有**(含推理帧)则切下一候选,全部候选耗尽抛
|
|
677
|
+
``AITimeoutError``;思考型模型的推理帧算活动、会重置窗口;
|
|
671
678
|
- 首帧后:帧间隔超过 ``_STREAM_FRAME_INTERVAL_TIMEOUT``(120s)
|
|
672
679
|
未收到新帧则中断并抛 ``AITimeoutError``(已产出 token 不可回滚)。
|
|
673
680
|
|
|
@@ -678,6 +685,14 @@ class AI:
|
|
|
678
685
|
yield token
|
|
679
686
|
if done:
|
|
680
687
|
break
|
|
688
|
+
|
|
689
|
+
流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
|
|
690
|
+
``truncated``,用于区分「模型没返回内容」与「输出预算被思考吃光」::
|
|
691
|
+
|
|
692
|
+
with AI.chat_stream(messages, max_tokens=450) as stream:
|
|
693
|
+
body = "".join(stream)
|
|
694
|
+
if not body and stream.truncated and stream.reasoning_chars:
|
|
695
|
+
... # 推理吃光预算:调大 max_tokens 或关闭思考
|
|
681
696
|
"""
|
|
682
697
|
return _StreamIterator(messages, max_tokens=max_tokens)
|
|
683
698
|
|
|
@@ -693,6 +708,10 @@ class _StreamIterator:
|
|
|
693
708
|
支持 ``with`` 上下文(推荐):退出时主动取消后台任务;即便调用方
|
|
694
709
|
直接 ``for`` 中途 ``break`` 或迭代器被 GC,``close``/``__del__`` 也会
|
|
695
710
|
兜底取消后台 producer,避免任务残留。
|
|
711
|
+
|
|
712
|
+
流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
|
|
713
|
+
``truncated`` 四个只读属性(跨线程共享同一累加器:结束后为最终值,
|
|
714
|
+
流式过程中为尽力而为的中间值;提前 ``break`` 时为截至中断的累计值)。
|
|
696
715
|
"""
|
|
697
716
|
|
|
698
717
|
def __init__(
|
|
@@ -701,6 +720,7 @@ class _StreamIterator:
|
|
|
701
720
|
self._messages: list[dict[str, str]] = messages
|
|
702
721
|
self._max_tokens: int | None = max_tokens
|
|
703
722
|
self._q: queue.Queue[object] = queue.Queue()
|
|
723
|
+
self._stats: StreamStats = StreamStats()
|
|
704
724
|
self._started = False
|
|
705
725
|
self._closed = False
|
|
706
726
|
self._got_first = False
|
|
@@ -713,7 +733,10 @@ class _StreamIterator:
|
|
|
713
733
|
if not self._started:
|
|
714
734
|
self._started = True
|
|
715
735
|
self._task = _background.submit_stream(
|
|
716
|
-
self._messages,
|
|
736
|
+
self._messages,
|
|
737
|
+
self._q,
|
|
738
|
+
max_tokens=self._max_tokens,
|
|
739
|
+
stats=self._stats,
|
|
717
740
|
)
|
|
718
741
|
|
|
719
742
|
def _cancel_task(self) -> None:
|
|
@@ -766,6 +789,28 @@ class _StreamIterator:
|
|
|
766
789
|
self._got_first = True
|
|
767
790
|
return str(item)
|
|
768
791
|
|
|
792
|
+
# ---------- 收尾统计(只读) ----------
|
|
793
|
+
|
|
794
|
+
@property
|
|
795
|
+
def reasoning_chars(self) -> int:
|
|
796
|
+
"""推理(思考)内容字符数;非思考模型恒为 0。"""
|
|
797
|
+
return self._stats.reasoning_chars
|
|
798
|
+
|
|
799
|
+
@property
|
|
800
|
+
def content_chars(self) -> int:
|
|
801
|
+
"""已产出正文字符数(与本迭代器产出的字符数严格相等)。"""
|
|
802
|
+
return self._stats.content_chars
|
|
803
|
+
|
|
804
|
+
@property
|
|
805
|
+
def finish_reason(self) -> str | None:
|
|
806
|
+
"""结束原因:``stop`` / ``length`` / ``content_filter`` / …;未给出为 None。"""
|
|
807
|
+
return self._stats.finish_reason
|
|
808
|
+
|
|
809
|
+
@property
|
|
810
|
+
def truncated(self) -> bool:
|
|
811
|
+
"""是否因输出长度上限被截断(``finish_reason`` 为 length / max_tokens)。"""
|
|
812
|
+
return self._stats.truncated
|
|
813
|
+
|
|
769
814
|
|
|
770
815
|
async def _delayed_close(services: list[Service]) -> None:
|
|
771
816
|
"""延迟回收旧 service:给在途调用留宽限期,到期统一关闭(须在 loop 内)。"""
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
|
|
16
|
-
Project: sdpy
|
|
17
|
-
Author: Clark Chang
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
import json
|
|
21
|
-
import time
|
|
22
|
-
from collections.abc import AsyncIterator
|
|
23
|
-
from dataclasses import dataclass
|
|
24
|
-
from typing import ClassVar, cast, override
|
|
25
|
-
|
|
26
|
-
from kit.ai.base_service import (
|
|
27
|
-
_LOG_PAYLOAD_LIMIT,
|
|
28
|
-
BaseAIService,
|
|
29
|
-
_clip,
|
|
30
|
-
_truncate,
|
|
31
|
-
safe_get,
|
|
32
|
-
)
|
|
33
|
-
from kit.ai.exceptions import AIError, AIResponseError
|
|
34
|
-
from kit.ai.types import JSONObject
|
|
35
|
-
from kit.config.log_config import get_default_logger
|
|
36
|
-
|
|
37
|
-
__all__ = ["ChatRequest", "ChatService"]
|
|
38
|
-
|
|
39
|
-
logger = get_default_logger(__name__)
|
|
40
|
-
|
|
41
|
-
Message = dict[str, str]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@dataclass(frozen=True)
|
|
45
|
-
class ChatRequest:
|
|
46
|
-
"""对话请求模型。"""
|
|
47
|
-
|
|
48
|
-
messages: list[Message]
|
|
49
|
-
temperature: float | None = None
|
|
50
|
-
max_tokens: int | None = None
|
|
51
|
-
stream: bool = False
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class ChatService(BaseAIService[ChatRequest, str]):
|
|
55
|
-
"""对话类模型服务(全异步)。"""
|
|
56
|
-
|
|
57
|
-
_LABEL: ClassVar[str] = "对话模型"
|
|
58
|
-
|
|
59
|
-
@override
|
|
60
|
-
def _build_payload(self, request: ChatRequest) -> JSONObject:
|
|
61
|
-
payload: dict[str, object] = {
|
|
62
|
-
"model": self._profile.model,
|
|
63
|
-
"messages": request.messages,
|
|
64
|
-
"stream": request.stream,
|
|
65
|
-
}
|
|
66
|
-
if request.temperature is not None:
|
|
67
|
-
payload["temperature"] = request.temperature
|
|
68
|
-
# max_tokens 逐级回落:调用点显式传入 → 档案配置 → 不写(provider 默认)
|
|
69
|
-
max_tokens = (
|
|
70
|
-
request.max_tokens
|
|
71
|
-
if request.max_tokens is not None
|
|
72
|
-
else self._profile.max_tokens
|
|
73
|
-
)
|
|
74
|
-
if max_tokens is not None:
|
|
75
|
-
payload["max_tokens"] = max_tokens
|
|
76
|
-
return cast(JSONObject, payload)
|
|
77
|
-
|
|
78
|
-
@override
|
|
79
|
-
def _parse_response(self, data: JSONObject) -> str:
|
|
80
|
-
content = safe_get(data, "choices", 0, "message", "content")
|
|
81
|
-
if not isinstance(content, str):
|
|
82
|
-
raise AIResponseError(f"content 类型异常: {type(content).__name__}")
|
|
83
|
-
return content
|
|
84
|
-
|
|
85
|
-
async def ask(
|
|
86
|
-
self,
|
|
87
|
-
messages: list[Message],
|
|
88
|
-
*,
|
|
89
|
-
temperature: float | None = None,
|
|
90
|
-
max_tokens: int | None = None,
|
|
91
|
-
deadline: float | None = None,
|
|
92
|
-
) -> str:
|
|
93
|
-
"""多轮对话,返回模型回答文本。
|
|
94
|
-
|
|
95
|
-
Args:
|
|
96
|
-
deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
|
|
97
|
-
5xx 重试预算;None 表示不限制
|
|
98
|
-
"""
|
|
99
|
-
request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
|
|
100
|
-
return await self._call(request, deadline=deadline)
|
|
101
|
-
|
|
102
|
-
async def complete(
|
|
103
|
-
self,
|
|
104
|
-
prompt: str,
|
|
105
|
-
*,
|
|
106
|
-
temperature: float | None = None,
|
|
107
|
-
max_tokens: int | None = None,
|
|
108
|
-
deadline: float | None = None,
|
|
109
|
-
) -> str:
|
|
110
|
-
messages: list[Message] = [{"role": "user", "content": prompt}]
|
|
111
|
-
return await self.ask(
|
|
112
|
-
messages, temperature=temperature, max_tokens=max_tokens, deadline=deadline
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
async def stream(
|
|
116
|
-
self,
|
|
117
|
-
messages: list[Message],
|
|
118
|
-
*,
|
|
119
|
-
temperature: float | None = None,
|
|
120
|
-
max_tokens: int | None = None,
|
|
121
|
-
) -> AsyncIterator[str]:
|
|
122
|
-
request = ChatRequest(
|
|
123
|
-
messages, temperature=temperature, max_tokens=max_tokens, stream=True
|
|
124
|
-
)
|
|
125
|
-
payload = self._build_payload(request)
|
|
126
|
-
desc = self._log_desc()
|
|
127
|
-
params = _truncate(
|
|
128
|
-
json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
|
|
129
|
-
)
|
|
130
|
-
# 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
|
|
131
|
-
logger.info(f"********** {desc} ********** Start")
|
|
132
|
-
logger.info(f"请求参数: {params}")
|
|
133
|
-
start = time.perf_counter()
|
|
134
|
-
note = ""
|
|
135
|
-
failed = False
|
|
136
|
-
try:
|
|
137
|
-
async for parsed in self._client.stream_json(payload):
|
|
138
|
-
if parsed is None:
|
|
139
|
-
continue
|
|
140
|
-
try:
|
|
141
|
-
delta = safe_get(parsed, "choices", 0, "delta", "content")
|
|
142
|
-
except AIResponseError:
|
|
143
|
-
# 流式结束帧等无 delta 的正常帧,跳过
|
|
144
|
-
logger.debug("流式帧缺少 delta.content,跳过")
|
|
145
|
-
continue
|
|
146
|
-
if isinstance(delta, str) and delta:
|
|
147
|
-
yield delta
|
|
148
|
-
except AIError as exc:
|
|
149
|
-
note = f"(失败:{exc})"
|
|
150
|
-
failed = True
|
|
151
|
-
raise
|
|
152
|
-
except BaseException: # noqa: BLE001 - 中断路径(提前 break/取消)也需收尾日志
|
|
153
|
-
note = "(中断)"
|
|
154
|
-
raise
|
|
155
|
-
finally:
|
|
156
|
-
log = logger.error if failed else logger.info
|
|
157
|
-
log(
|
|
158
|
-
f"********** {desc} ********** "
|
|
159
|
-
f"耗时 {time.perf_counter() - start:.1f}秒{note}"
|
|
160
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|