sdpy-kit 0.6.3__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.3 → sdpy_kit-0.6.5}/PKG-INFO +42 -9
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/README.md +41 -8
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/__init__.py +1 -1
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/__init__.py +2 -1
- {sdpy_kit-0.6.3 → 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.3 → sdpy_kit-0.6.5}/kit/ai/failover.py +66 -19
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/profiles.py +9 -1
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/runner.py +48 -3
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/md2doc_kit.py +540 -14
- sdpy_kit-0.6.3/kit/ai/chat_service.py +0 -160
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/.gitignore +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/pool.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/ai/types.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/common_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/audit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/crud.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/engine.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/query.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/session.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/file_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/license_kit.py +0 -0
- {sdpy_kit-0.6.3 → sdpy_kit-0.6.5}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.6.3 → 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
|
|
@@ -98,7 +98,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
|
|
|
98
98
|
| `kit.docs.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
|
|
99
99
|
| `kit.docs.docx_kit` | `DocxKit` | Word 模板动态填充(章节 / 列表 / 表格) | `docx` |
|
|
100
100
|
| `kit.docs.rc_docparser_kit` | `RcDocParserKit` | 离线 docx → Markdown + Images(无模型) | `docx-parse` |
|
|
101
|
-
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
|
|
101
|
+
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML(含图片) | `md2doc` |
|
|
102
102
|
| `kit.docs.mineru_kit` | `MinerUKit` | MinerU 文档解析(精准 + Agent 轻量) | `mineru` |
|
|
103
103
|
| `kit.docs.marker_kit` | `MarkerKit` | 本地解析(PDF / 图片 → Markdown + Images) | `marker` |
|
|
104
104
|
| `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
|
|
@@ -125,7 +125,7 @@ uv add sdpy-kit
|
|
|
125
125
|
| `sqliteai` | SQLiteKit 向量方法(numpy / sqliteai-vector,SQLite 向量扩展) | `pip install "sdpy-kit[sqliteai]"` |
|
|
126
126
|
| `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
|
|
127
127
|
| `ai` | AI 服务池(httpx + python-dotenv) | `pip install "sdpy-kit[ai]"` |
|
|
128
|
-
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2
|
|
128
|
+
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2,含图片) | `pip install "sdpy-kit[md2doc]"` |
|
|
129
129
|
| `anydoc` | AnyDocKit 文档转 Markdown(firecrawl-anydoc + pikepdf) | `pip install "sdpy-kit[anydoc]"` |
|
|
130
130
|
| `docx` | DocxKit 模板动态填充(docxtpl) | `pip install "sdpy-kit[docx]"` |
|
|
131
131
|
| `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
|
|
@@ -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` |
|
|
@@ -1076,12 +1102,18 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
|
|
|
1076
1102
|
|
|
1077
1103
|
| 方法 | 说明 |
|
|
1078
1104
|
|------|------|
|
|
1079
|
-
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word
|
|
1080
|
-
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5
|
|
1081
|
-
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF
|
|
1105
|
+
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
|
|
1106
|
+
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
|
|
1107
|
+
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
|
|
1082
1108
|
|
|
1083
1109
|
> `dest_path=None` 时输出到源文件同目录;同一源文件不支持并发转换。
|
|
1084
1110
|
|
|
1111
|
+
**图片**:`` 以 Markdown 源文件所在目录为基准解析,宽度等比限幅
|
|
1112
|
+
(96dpi 还原自然尺寸、不放大),超高先分页/按版心收紧;缺图、越界路径、非图片
|
|
1113
|
+
格式一律回落 `alt` 文本,不抛异常。远程地址在 PDF / Word 中不下载(回落 alt),
|
|
1114
|
+
在 HTML 中直出 `<img src="https://…">` 交给浏览器。HTML 输出保留相对路径,需与
|
|
1115
|
+
`images/` 目录保持同层级才能在浏览器中显示。
|
|
1116
|
+
|
|
1085
1117
|
---
|
|
1086
1118
|
|
|
1087
1119
|
## kit.docs.mineru_kit —— MinerU 文档解析
|
|
@@ -1348,6 +1380,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
|
|
|
1348
1380
|
| 档案级 | `timeout` | 请求超时秒数(默认 60) |
|
|
1349
1381
|
| 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
|
|
1350
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)` 生效 |
|
|
1351
1384
|
| 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
|
|
1352
1385
|
|
|
1353
1386
|
### 候选链切换语义(两类,勿混淆)
|
|
@@ -22,7 +22,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
|
|
|
22
22
|
| `kit.docs.anydoc_kit` | `AnyDocKit` | docx / pdf / xlsx 等 → Markdown | `anydoc` |
|
|
23
23
|
| `kit.docs.docx_kit` | `DocxKit` | Word 模板动态填充(章节 / 列表 / 表格) | `docx` |
|
|
24
24
|
| `kit.docs.rc_docparser_kit` | `RcDocParserKit` | 离线 docx → Markdown + Images(无模型) | `docx-parse` |
|
|
25
|
-
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML | `md2doc` |
|
|
25
|
+
| `kit.docs.md2doc_kit` | `Md2DocKit` | Markdown → Word / PDF / HTML(含图片) | `md2doc` |
|
|
26
26
|
| `kit.docs.mineru_kit` | `MinerUKit` | MinerU 文档解析(精准 + Agent 轻量) | `mineru` |
|
|
27
27
|
| `kit.docs.marker_kit` | `MarkerKit` | 本地解析(PDF / 图片 → Markdown + Images) | `marker` |
|
|
28
28
|
| `kit.laya_kit` | `LayaKit` | 类型化决策:choice / score / noul,返回判断结果 + 置信度 | `laya` |
|
|
@@ -49,7 +49,7 @@ uv add sdpy-kit
|
|
|
49
49
|
| `sqliteai` | SQLiteKit 向量方法(numpy / sqliteai-vector,SQLite 向量扩展) | `pip install "sdpy-kit[sqliteai]"` |
|
|
50
50
|
| `redis` | RedisKit | `pip install "sdpy-kit[redis]"` |
|
|
51
51
|
| `ai` | AI 服务池(httpx + python-dotenv) | `pip install "sdpy-kit[ai]"` |
|
|
52
|
-
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2
|
|
52
|
+
| `md2doc` | Md2DocKit:Markdown → Word / PDF / HTML(pimd / fpdf2,含图片) | `pip install "sdpy-kit[md2doc]"` |
|
|
53
53
|
| `anydoc` | AnyDocKit 文档转 Markdown(firecrawl-anydoc + pikepdf) | `pip install "sdpy-kit[anydoc]"` |
|
|
54
54
|
| `docx` | DocxKit 模板动态填充(docxtpl) | `pip install "sdpy-kit[docx]"` |
|
|
55
55
|
| `docx-parse` | RcDocParserKit 离线 docx 解析(rc-docparser → Markdown + images/) | `pip install "sdpy-kit[docx-parse]"` |
|
|
@@ -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` |
|
|
@@ -1000,12 +1026,18 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
|
|
|
1000
1026
|
|
|
1001
1027
|
| 方法 | 说明 |
|
|
1002
1028
|
|------|------|
|
|
1003
|
-
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word
|
|
1004
|
-
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5
|
|
1005
|
-
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF
|
|
1029
|
+
| `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
|
|
1030
|
+
| `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
|
|
1031
|
+
| `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
|
|
1006
1032
|
|
|
1007
1033
|
> `dest_path=None` 时输出到源文件同目录;同一源文件不支持并发转换。
|
|
1008
1034
|
|
|
1035
|
+
**图片**:`` 以 Markdown 源文件所在目录为基准解析,宽度等比限幅
|
|
1036
|
+
(96dpi 还原自然尺寸、不放大),超高先分页/按版心收紧;缺图、越界路径、非图片
|
|
1037
|
+
格式一律回落 `alt` 文本,不抛异常。远程地址在 PDF / Word 中不下载(回落 alt),
|
|
1038
|
+
在 HTML 中直出 `<img src="https://…">` 交给浏览器。HTML 输出保留相对路径,需与
|
|
1039
|
+
`images/` 目录保持同层级才能在浏览器中显示。
|
|
1040
|
+
|
|
1009
1041
|
---
|
|
1010
1042
|
|
|
1011
1043
|
## kit.docs.mineru_kit —— MinerU 文档解析
|
|
@@ -1272,6 +1304,7 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
|
|
|
1272
1304
|
| 档案级 | `timeout` | 请求超时秒数(默认 60) |
|
|
1273
1305
|
| 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
|
|
1274
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)` 生效 |
|
|
1275
1308
|
| 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
|
|
1276
1309
|
|
|
1277
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
|
+
)
|