zero-agent-core 0.2.0__py3-none-any.whl
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.
- agent_core/__init__.py +525 -0
- agent_core/access/__init__.py +6 -0
- agent_core/access/context.py +45 -0
- agent_core/access/guard.py +40 -0
- agent_core/acp/README.md +236 -0
- agent_core/acp/__init__.py +20 -0
- agent_core/acp/server.py +351 -0
- agent_core/acp/types.py +86 -0
- agent_core/callbacks/__init__.py +17 -0
- agent_core/callbacks/manager.py +105 -0
- agent_core/checkpoint/__init__.py +19 -0
- agent_core/checkpoint/naming.py +93 -0
- agent_core/checkpoint/store.py +186 -0
- agent_core/checkpoint/travel.py +448 -0
- agent_core/compaction/__init__.py +57 -0
- agent_core/compaction/middleware.py +428 -0
- agent_core/compaction/prompts.py +32 -0
- agent_core/compaction/pruner.py +121 -0
- agent_core/compaction/region.py +145 -0
- agent_core/compaction/spill.py +81 -0
- agent_core/compaction/spill_tool.py +42 -0
- agent_core/compaction/summarizer.py +95 -0
- agent_core/compaction/token_limit.py +74 -0
- agent_core/compaction/types.py +93 -0
- agent_core/docs.py +15 -0
- agent_core/documents/__init__.py +23 -0
- agent_core/documents/loaders.py +79 -0
- agent_core/documents/splitters.py +94 -0
- agent_core/documents/types.py +197 -0
- agent_core/errors.py +81 -0
- agent_core/guardrails/__init__.py +19 -0
- agent_core/guardrails/core.py +186 -0
- agent_core/hitl/__init__.py +15 -0
- agent_core/hitl/core.py +517 -0
- agent_core/mcp/__init__.py +14 -0
- agent_core/mcp/client.py +212 -0
- agent_core/mcp/errors.py +26 -0
- agent_core/middleware/__init__.py +26 -0
- agent_core/middleware/base.py +283 -0
- agent_core/model/__init__.py +73 -0
- agent_core/model/catalog.py +73 -0
- agent_core/model/core.py +179 -0
- agent_core/model/governance.py +317 -0
- agent_core/model/providers/__init__.py +43 -0
- agent_core/model/providers/anthropic.py +294 -0
- agent_core/model/providers/common.py +28 -0
- agent_core/model/providers/factory.py +107 -0
- agent_core/model/providers/gemini.py +273 -0
- agent_core/model/providers/ollama.py +80 -0
- agent_core/model/providers/openai_compatible.py +526 -0
- agent_core/model/structured.py +200 -0
- agent_core/observability/__init__.py +21 -0
- agent_core/observability/core.py +311 -0
- agent_core/ports/__init__.py +31 -0
- agent_core/ports/memory.py +130 -0
- agent_core/ports/model_selection.py +64 -0
- agent_core/ports/storage.py +114 -0
- agent_core/prompts/__init__.py +19 -0
- agent_core/prompts/registry.py +236 -0
- agent_core/prompts/templates.py +166 -0
- agent_core/protocol/__init__.py +31 -0
- agent_core/protocol/capabilities.py +49 -0
- agent_core/protocol/messages.py +100 -0
- agent_core/protocol/runtime.py +293 -0
- agent_core/py.typed +1 -0
- agent_core/retrieval/__init__.py +40 -0
- agent_core/retrieval/_common.py +121 -0
- agent_core/retrieval/chroma.py +269 -0
- agent_core/retrieval/core.py +63 -0
- agent_core/retrieval/factory.py +72 -0
- agent_core/retrieval/memory.py +232 -0
- agent_core/retrieval/pgvector.py +239 -0
- agent_core/retrieval/types.py +95 -0
- agent_core/runtime/__init__.py +28 -0
- agent_core/runtime/compat.py +98 -0
- agent_core/runtime/react.py +888 -0
- agent_core/runtime/service.py +557 -0
- agent_core/runtime/store.py +55 -0
- agent_core/serialization/__init__.py +21 -0
- agent_core/serialization/registry.py +318 -0
- agent_core/skills/__init__.py +24 -0
- agent_core/skills/errors.py +27 -0
- agent_core/skills/loader.py +258 -0
- agent_core/skills/registry.py +205 -0
- agent_core/skills/types.py +56 -0
- agent_core/storage/__init__.py +107 -0
- agent_core/storage/_sqlite_utils.py +92 -0
- agent_core/storage/context.py +382 -0
- agent_core/storage/lease.py +435 -0
- agent_core/storage/memory.py +604 -0
- agent_core/storage/model_selection.py +360 -0
- agent_core/storage/runtime.py +899 -0
- agent_core/storage/session.py +904 -0
- agent_core/storage/workflow.py +411 -0
- agent_core/tools/__init__.py +5 -0
- agent_core/tools/executor.py +306 -0
- agent_core/workflow/__init__.py +25 -0
- agent_core/workflow/durable.py +175 -0
- agent_core/workflow/state_machine.py +427 -0
- zero_agent_core-0.2.0.dist-info/METADATA +237 -0
- zero_agent_core-0.2.0.dist-info/RECORD +104 -0
- zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
- zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
- zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""适配器 1:OpenAI / 兼容接口(默认)。
|
|
2
|
+
|
|
3
|
+
DashScope(Qwen)、Ollama 等 OpenAI 兼容端点复用该实现;
|
|
4
|
+
Qwen 本地 tokenizer 与 tokenizer API 探测辅助也位于本模块。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agent_core.errors import ModelInvocationError
|
|
15
|
+
from agent_core.model.core import ContextUsage, StreamChunk
|
|
16
|
+
from agent_core.model.providers.common import known_context_window, logger
|
|
17
|
+
from agent_core.protocol.messages import Message, assistant_message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── token / 上下文窗口辅助函数 ────────────────────────────────
|
|
21
|
+
def _read_token_count(value: Any) -> int | None:
|
|
22
|
+
candidates = [value]
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
output = value.get("output")
|
|
25
|
+
candidates.extend([value.get("usage"), value.get("data"), output])
|
|
26
|
+
if isinstance(output, dict):
|
|
27
|
+
candidates.append(output.get("usage"))
|
|
28
|
+
for candidate in candidates:
|
|
29
|
+
if not isinstance(candidate, dict):
|
|
30
|
+
continue
|
|
31
|
+
for key in ("prompt_tokens", "input_tokens", "total_tokens", "token_count"):
|
|
32
|
+
token_count = candidate.get(key)
|
|
33
|
+
if isinstance(token_count, int) and token_count >= 0:
|
|
34
|
+
return token_count
|
|
35
|
+
token_ids = candidate.get("token_ids")
|
|
36
|
+
if isinstance(token_ids, list):
|
|
37
|
+
return len(token_ids)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── token / 统计完整模型输入辅助函数 ────────────────────────────────
|
|
42
|
+
def _count_qwen_tokens(
|
|
43
|
+
messages: list[Message],
|
|
44
|
+
tools: list[dict[str, Any]] | None,
|
|
45
|
+
) -> int | None:
|
|
46
|
+
"""使用 DashScope SDK 随附的官方 Qwen tokenizer 统计完整模型输入。"""
|
|
47
|
+
try:
|
|
48
|
+
# 导入 DashScope tokenizer
|
|
49
|
+
from dashscope.tokenizers import get_tokenizer
|
|
50
|
+
|
|
51
|
+
# 获取 Qwen tokenizer
|
|
52
|
+
tokenizer = get_tokenizer("qwen-plus")
|
|
53
|
+
parts: list[str] = []
|
|
54
|
+
for message in messages:
|
|
55
|
+
# 构建消息内容
|
|
56
|
+
parts.extend(["<|im_start|>", message.role, "\n", message.content])
|
|
57
|
+
# 处理工具调用
|
|
58
|
+
if message.tool_calls:
|
|
59
|
+
parts.append(json.dumps(message.tool_calls, ensure_ascii=False, separators=(",", ":")))
|
|
60
|
+
if message.name:
|
|
61
|
+
parts.extend(["\nname:", message.name])
|
|
62
|
+
if message.tool_call_id:
|
|
63
|
+
parts.extend(["\ntool_call_id:", message.tool_call_id])
|
|
64
|
+
parts.append("<|im_end|>\n")
|
|
65
|
+
if tools:
|
|
66
|
+
# 构建工具定义
|
|
67
|
+
parts.extend(
|
|
68
|
+
[
|
|
69
|
+
"<|im_start|>system\n# Tools\n",
|
|
70
|
+
json.dumps(tools, ensure_ascii=False, separators=(",", ":")),
|
|
71
|
+
"<|im_end|>\n",
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
# 构建助手消息
|
|
75
|
+
parts.append("<|im_start|>assistant\n")
|
|
76
|
+
# 编码消息
|
|
77
|
+
token_ids = tokenizer.encode("".join(parts))
|
|
78
|
+
# 返回 token 数
|
|
79
|
+
return len(token_ids)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
logger.debug("本地 Qwen tokenizer 不可用:%s", exc)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── token / 上下文窗口辅助函数 ────────────────────────────────
|
|
86
|
+
def _read_context_window(value: Any) -> int | None:
|
|
87
|
+
if not isinstance(value, dict):
|
|
88
|
+
return None
|
|
89
|
+
candidates = [value, value.get("data"), value.get("model")] # 从多个字段中读取上下文窗口大小
|
|
90
|
+
for candidate in candidates: # 遍历候选字段
|
|
91
|
+
if not isinstance(candidate, dict): # 跳过非字典字段
|
|
92
|
+
continue
|
|
93
|
+
for key in (
|
|
94
|
+
"context_window_tokens",
|
|
95
|
+
"context_length",
|
|
96
|
+
"max_context_length",
|
|
97
|
+
"max_input_tokens",
|
|
98
|
+
"input_token_limit",
|
|
99
|
+
):
|
|
100
|
+
context_window = candidate.get(key)
|
|
101
|
+
if isinstance(context_window, int) and context_window > 0:
|
|
102
|
+
return context_window
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class OpenAIProvider:
|
|
107
|
+
"""OpenAI 兼容接口的模型适配器。
|
|
108
|
+
|
|
109
|
+
功能:
|
|
110
|
+
1. 普通 chat completion(支持 tool calling)
|
|
111
|
+
2. 流式 chat(支持工具调用收集)
|
|
112
|
+
3. JSON 结构化输出
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
provider_name = "openai"
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
api_key: str,
|
|
121
|
+
base_url: str = "https://api.openai.com/v1",
|
|
122
|
+
model: str = "gpt-4o-mini",
|
|
123
|
+
temperature: float = 0.0,
|
|
124
|
+
max_tokens: int = 4096,
|
|
125
|
+
context_window_tokens: int | None = None, # 上下文窗口 token 数
|
|
126
|
+
) -> None:
|
|
127
|
+
# 只在真正选择 OpenAI 提供商时加载 SDK,导入 Core 其他能力不应依赖它。
|
|
128
|
+
try:
|
|
129
|
+
from openai import AsyncOpenAI
|
|
130
|
+
except ImportError as exc:
|
|
131
|
+
raise ModelInvocationError("OpenAI 提供商需要 openai 依赖,请先安装项目运行依赖。") from exc
|
|
132
|
+
self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) # 初始化 OpenAI 客户端
|
|
133
|
+
self._api_key = api_key # API 密钥
|
|
134
|
+
self._base_url = base_url.rstrip("/") # 去掉末尾的斜杠
|
|
135
|
+
self._model = model # 模型名称
|
|
136
|
+
# 暴露只读语义的公开元数据,便于日志、路由和运行审计使用。
|
|
137
|
+
self.model = model # 模型名称
|
|
138
|
+
self._temperature = temperature # 温度参数
|
|
139
|
+
self._max_tokens = max_tokens # 最大 token 数
|
|
140
|
+
self._configured_context_window_tokens = context_window_tokens # 上下文窗口 token 数
|
|
141
|
+
self._context_window_cache: int | None = None # 上下文窗口 token 数缓存,用于优化调用
|
|
142
|
+
|
|
143
|
+
# ──────────────────────────────────────────────
|
|
144
|
+
# 普通对话
|
|
145
|
+
# ──────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
async def chat(
|
|
148
|
+
self,
|
|
149
|
+
messages: list[Message],
|
|
150
|
+
*,
|
|
151
|
+
tools: list[dict[str, Any]] | None = None,
|
|
152
|
+
temperature: float | None = None,
|
|
153
|
+
max_tokens: int | None = None,
|
|
154
|
+
) -> Message:
|
|
155
|
+
"""调用 LLM 进行对话。
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
messages: 消息历史列表。
|
|
159
|
+
tools: 可选的工具定义列表(OpenAI function calling 格式)。
|
|
160
|
+
temperature: 覆盖默认温度。
|
|
161
|
+
max_tokens: 覆盖默认最大 token 数。
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
LLM 的回复消息(assistant 角色)。
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
ModelInvocationError: LLM 调用失败。
|
|
168
|
+
"""
|
|
169
|
+
openai_messages = [msg.to_openai_dict() for msg in messages] # 转换为 OpenAI 格式的消息
|
|
170
|
+
|
|
171
|
+
# 构建请求参数
|
|
172
|
+
kwargs: dict[str, Any] = {
|
|
173
|
+
"model": self._model,
|
|
174
|
+
"messages": openai_messages,
|
|
175
|
+
"temperature": temperature if temperature is not None else self._temperature,
|
|
176
|
+
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# 如果有工具定义,添加到请求参数
|
|
180
|
+
if tools:
|
|
181
|
+
kwargs["tools"] = tools
|
|
182
|
+
kwargs["tool_choice"] = "auto"
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
logger.debug(
|
|
186
|
+
"LLM call: model=%s, messages=%d, tools=%d",
|
|
187
|
+
self._model,
|
|
188
|
+
len(messages),
|
|
189
|
+
len(tools) if tools else 0,
|
|
190
|
+
)
|
|
191
|
+
# 调用 OpenAI API
|
|
192
|
+
response = await self._client.chat.completions.create(**kwargs)
|
|
193
|
+
except Exception as exc:
|
|
194
|
+
raise ModelInvocationError(f"OpenAI API call failed: {exc}") from exc
|
|
195
|
+
|
|
196
|
+
if not response.choices: # 检查是否有有效回复
|
|
197
|
+
raise ModelInvocationError("OpenAI API returned no choices.")
|
|
198
|
+
# 解析响应
|
|
199
|
+
choice = response.choices[0] # 取第一个回复
|
|
200
|
+
message = choice.message # 取回复消息
|
|
201
|
+
content = message.content or "" # 取回复内容
|
|
202
|
+
tool_calls: list[dict[str, Any]] = [] # 初始化工具调用列表
|
|
203
|
+
# 解析工具调用
|
|
204
|
+
if message.tool_calls:
|
|
205
|
+
for tc in message.tool_calls: # 遍历工具调用
|
|
206
|
+
try:
|
|
207
|
+
arguments = json.loads(tc.function.arguments)
|
|
208
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
209
|
+
raise ModelInvocationError(
|
|
210
|
+
f"OpenAI 返回的工具 {tc.function.name!r} 参数不是有效 JSON:{exc}"
|
|
211
|
+
) from exc
|
|
212
|
+
if not isinstance(arguments, dict):
|
|
213
|
+
raise ModelInvocationError(f"OpenAI 返回的工具 {tc.function.name!r} 参数必须是 JSON 对象")
|
|
214
|
+
tool_calls.append(
|
|
215
|
+
{
|
|
216
|
+
"id": tc.id, # 工具调用 ID
|
|
217
|
+
"type": "function", # 工具调用类型
|
|
218
|
+
# 工具名称
|
|
219
|
+
"name": tc.function.name,
|
|
220
|
+
# 工具参数
|
|
221
|
+
"args": arguments,
|
|
222
|
+
}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# 记录日志
|
|
226
|
+
logger.debug(
|
|
227
|
+
"LLM response: content_len=%d, tool_calls=%d, finish=%s",
|
|
228
|
+
len(content),
|
|
229
|
+
len(tool_calls),
|
|
230
|
+
choice.finish_reason,
|
|
231
|
+
)
|
|
232
|
+
# 返回回复消息
|
|
233
|
+
return assistant_message(content, tool_calls=tool_calls)
|
|
234
|
+
|
|
235
|
+
# ──────────────────────────────────────────────
|
|
236
|
+
# 上下文使用
|
|
237
|
+
# ──────────────────────────────────────────────
|
|
238
|
+
async def context_usage(
|
|
239
|
+
self,
|
|
240
|
+
messages: list[Message],
|
|
241
|
+
*,
|
|
242
|
+
tools: list[dict[str, Any]] | None = None,
|
|
243
|
+
) -> ContextUsage:
|
|
244
|
+
"""通过兼容接口的 Tokenize API 获取精确输入 token 数。"""
|
|
245
|
+
payload: dict[str, Any] = {
|
|
246
|
+
"model": self._model,
|
|
247
|
+
"messages": [message.to_openai_dict() for message in messages],
|
|
248
|
+
}
|
|
249
|
+
if tools:
|
|
250
|
+
payload["tools"] = tools
|
|
251
|
+
|
|
252
|
+
input_tokens: int | None = None
|
|
253
|
+
source = "unavailable" # 初始化来源为不可用
|
|
254
|
+
if self._model.lower().startswith("qwen"): # 如果是 Qwen 模型
|
|
255
|
+
input_tokens = _count_qwen_tokens(messages, tools) # 计算 Qwen 模型的 token 数
|
|
256
|
+
if input_tokens is not None: # 如果计算成功
|
|
257
|
+
source = "official_qwen_tokenizer"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
import httpx
|
|
261
|
+
|
|
262
|
+
requests: list[tuple[str, dict[str, Any]]] = [
|
|
263
|
+
(f"{self._base_url}/tokenizer", payload),
|
|
264
|
+
(f"{self._base_url}/tokenize", payload),
|
|
265
|
+
]
|
|
266
|
+
if "dashscope.aliyuncs.com" in self._base_url:
|
|
267
|
+
native_payload = {
|
|
268
|
+
"model": self._model,
|
|
269
|
+
"input": {
|
|
270
|
+
"messages": payload["messages"],
|
|
271
|
+
**({"tools": tools} if tools else {}),
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
requests.insert(
|
|
275
|
+
0,
|
|
276
|
+
(
|
|
277
|
+
"https://dashscope.aliyuncs.com/api/v1/tokenizer",
|
|
278
|
+
native_payload,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
async with httpx.AsyncClient(timeout=15) as client:
|
|
283
|
+
for url, request_payload in requests:
|
|
284
|
+
if input_tokens is not None:
|
|
285
|
+
break
|
|
286
|
+
response = await client.post(
|
|
287
|
+
url,
|
|
288
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
289
|
+
json=request_payload,
|
|
290
|
+
)
|
|
291
|
+
if not response.is_success:
|
|
292
|
+
continue
|
|
293
|
+
input_tokens = _read_token_count(response.json())
|
|
294
|
+
if input_tokens is not None:
|
|
295
|
+
source = "provider_tokenizer"
|
|
296
|
+
break
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
logger.debug("模型 tokenizer 不可用:%s", exc)
|
|
299
|
+
|
|
300
|
+
context_window_tokens = await self._context_window_tokens()
|
|
301
|
+
return ContextUsage(
|
|
302
|
+
input_tokens=input_tokens,
|
|
303
|
+
context_window_tokens=context_window_tokens,
|
|
304
|
+
exact=input_tokens is not None,
|
|
305
|
+
source=source,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# ──────────────────────────────────────────────
|
|
309
|
+
# 读取模型上下文上限
|
|
310
|
+
# ──────────────────────────────────────────────
|
|
311
|
+
async def _context_window_tokens(self) -> int | None:
|
|
312
|
+
if self._configured_context_window_tokens:
|
|
313
|
+
return self._configured_context_window_tokens
|
|
314
|
+
if self._context_window_cache is not None:
|
|
315
|
+
return self._context_window_cache
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
import httpx
|
|
319
|
+
|
|
320
|
+
async with httpx.AsyncClient(timeout=8) as client:
|
|
321
|
+
response = await client.get(
|
|
322
|
+
f"{self._base_url}/models/{self._model}",
|
|
323
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
324
|
+
)
|
|
325
|
+
if response.is_success:
|
|
326
|
+
value = _read_context_window(response.json())
|
|
327
|
+
if value is not None:
|
|
328
|
+
self._context_window_cache = value
|
|
329
|
+
return value
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
logger.debug("无法读取模型上下文上限:%s", exc)
|
|
332
|
+
|
|
333
|
+
value = known_context_window(self._model)
|
|
334
|
+
self._context_window_cache = value
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
# ──────────────────────────────────────────────
|
|
338
|
+
# 流式对话(支持工具调用收集)
|
|
339
|
+
# ──────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
async def stream_chat(
|
|
342
|
+
self,
|
|
343
|
+
messages: list[Message],
|
|
344
|
+
*,
|
|
345
|
+
tools: list[dict[str, Any]] | None = None,
|
|
346
|
+
temperature: float | None = None,
|
|
347
|
+
max_tokens: int | None = None,
|
|
348
|
+
) -> AsyncIterator[StreamChunk]:
|
|
349
|
+
"""流式调用 LLM。
|
|
350
|
+
|
|
351
|
+
每次 yield 一个 StreamChunk:
|
|
352
|
+
- 纯文本回答时:chunk.text 有内容,chunk.tool_calls 为空
|
|
353
|
+
- 工具调用时:先 yield 空文本块,最后一块 chunk.tool_calls 有完整调用列表
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
messages: 消息历史列表。
|
|
357
|
+
tools: 可选的工具定义列表。
|
|
358
|
+
temperature: 覆盖默认温度。
|
|
359
|
+
max_tokens: 覆盖默认最大 token 数。
|
|
360
|
+
|
|
361
|
+
Yields:
|
|
362
|
+
StreamChunk 数据块。
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
ModelInvocationError: LLM 调用失败。
|
|
366
|
+
"""
|
|
367
|
+
# 转换为 OpenAI 格式的消息
|
|
368
|
+
openai_messages = [msg.to_openai_dict() for msg in messages]
|
|
369
|
+
# 构建请求参数
|
|
370
|
+
kwargs: dict[str, Any] = {
|
|
371
|
+
"model": self._model,
|
|
372
|
+
"messages": openai_messages,
|
|
373
|
+
"temperature": temperature if temperature is not None else self._temperature,
|
|
374
|
+
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
|
|
375
|
+
"stream": True,
|
|
376
|
+
}
|
|
377
|
+
# 如果有工具定义,添加到请求参数
|
|
378
|
+
if tools:
|
|
379
|
+
kwargs["tools"] = tools
|
|
380
|
+
kwargs["tool_choice"] = "auto"
|
|
381
|
+
# 调用 OpenAI API
|
|
382
|
+
try:
|
|
383
|
+
logger.debug(
|
|
384
|
+
"LLM stream call: model=%s, messages=%d, tools=%d",
|
|
385
|
+
self._model,
|
|
386
|
+
len(messages),
|
|
387
|
+
len(tools) if tools else 0,
|
|
388
|
+
)
|
|
389
|
+
# 调用 OpenAI API
|
|
390
|
+
stream = await self._client.chat.completions.create(**kwargs)
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
raise ModelInvocationError(f"OpenAI API stream call failed: {exc}") from exc
|
|
393
|
+
|
|
394
|
+
# 收集工具调用的增量数据(各 index 独立积累)
|
|
395
|
+
# tool_call_accum: index -> {"id": str, "name": str, "args": str}
|
|
396
|
+
tool_call_accum: dict[int, dict[str, str]] = {}
|
|
397
|
+
finish_reason: str | None = None
|
|
398
|
+
|
|
399
|
+
# 处理流式响应
|
|
400
|
+
try:
|
|
401
|
+
async for chunk in stream:
|
|
402
|
+
# 跳过空 chunk
|
|
403
|
+
if not chunk.choices:
|
|
404
|
+
continue
|
|
405
|
+
# 提取增量内容
|
|
406
|
+
delta = chunk.choices[0].delta
|
|
407
|
+
finish_reason = chunk.choices[0].finish_reason
|
|
408
|
+
|
|
409
|
+
# ── 文本内容块,直接 yield ──────────────────────
|
|
410
|
+
if delta.content:
|
|
411
|
+
yield StreamChunk(text=delta.content)
|
|
412
|
+
|
|
413
|
+
# ── 工具调用增量,累积不 yield ─────────────────
|
|
414
|
+
if delta.tool_calls:
|
|
415
|
+
for tc_delta in delta.tool_calls:
|
|
416
|
+
idx = tc_delta.index
|
|
417
|
+
if idx not in tool_call_accum:
|
|
418
|
+
tool_call_accum[idx] = {"id": "", "name": "", "args": ""}
|
|
419
|
+
|
|
420
|
+
if tc_delta.id:
|
|
421
|
+
tool_call_accum[idx]["id"] += tc_delta.id
|
|
422
|
+
if tc_delta.function and tc_delta.function.name:
|
|
423
|
+
tool_call_accum[idx]["name"] += tc_delta.function.name
|
|
424
|
+
if tc_delta.function and tc_delta.function.arguments:
|
|
425
|
+
tool_call_accum[idx]["args"] += tc_delta.function.arguments
|
|
426
|
+
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
raise ModelInvocationError(f"LLM stream failed: {exc}") from exc
|
|
429
|
+
|
|
430
|
+
# 流结束后,如果有工具调用,yield 最终 chunk 携带完整 tool_calls
|
|
431
|
+
if tool_call_accum:
|
|
432
|
+
tool_calls: list[dict[str, Any]] = []
|
|
433
|
+
for idx in sorted(tool_call_accum.keys()):
|
|
434
|
+
acc = tool_call_accum[idx]
|
|
435
|
+
try:
|
|
436
|
+
args = json.loads(acc["args"]) if acc["args"] else {}
|
|
437
|
+
except json.JSONDecodeError as exc:
|
|
438
|
+
raise ModelInvocationError(
|
|
439
|
+
f"OpenAI 流式工具 {acc['name']!r} 参数不是有效 JSON:{exc}"
|
|
440
|
+
) from exc
|
|
441
|
+
if not isinstance(args, dict):
|
|
442
|
+
raise ModelInvocationError(f"OpenAI 流式工具 {acc['name']!r} 参数必须是 JSON 对象")
|
|
443
|
+
# 构建工具调用记录
|
|
444
|
+
tool_calls.append(
|
|
445
|
+
{
|
|
446
|
+
"id": acc["id"],
|
|
447
|
+
"type": "function",
|
|
448
|
+
"name": acc["name"],
|
|
449
|
+
"args": args,
|
|
450
|
+
}
|
|
451
|
+
)
|
|
452
|
+
# 最后一个 chunk,包含完整 tool_calls
|
|
453
|
+
logger.debug(
|
|
454
|
+
"LLM stream end: model=%s, messages=%d, tools=%d",
|
|
455
|
+
self._model,
|
|
456
|
+
len(messages),
|
|
457
|
+
len(tools) if tools else 0,
|
|
458
|
+
)
|
|
459
|
+
yield StreamChunk(
|
|
460
|
+
tool_calls=tool_calls,
|
|
461
|
+
is_tool_call=True,
|
|
462
|
+
finish_reason=finish_reason,
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
# 纯文本结束,发一个 finish 信号
|
|
466
|
+
yield StreamChunk(finish_reason=finish_reason)
|
|
467
|
+
|
|
468
|
+
# ──────────────────────────────────────────────
|
|
469
|
+
# JSON 结构化输出
|
|
470
|
+
# ──────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
async def parse_json(
|
|
473
|
+
self,
|
|
474
|
+
messages: list[Message],
|
|
475
|
+
*,
|
|
476
|
+
schema: dict[str, Any] | None = None,
|
|
477
|
+
) -> dict[str, Any]:
|
|
478
|
+
"""调用 LLM 并解析 JSON 输出。
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
messages: 消息历史列表。
|
|
482
|
+
schema: 可选的 JSON schema(用于验证)。
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
解析后的 JSON 对象。
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
ModelInvocationError: LLM 调用失败或 JSON 解析失败。
|
|
489
|
+
"""
|
|
490
|
+
# Message 是可变对象,浅拷贝会把 JSON 指令永久追加到调用方的 system 消息。
|
|
491
|
+
enhanced_messages = copy.deepcopy(messages)
|
|
492
|
+
if enhanced_messages and enhanced_messages[0].role == "system":
|
|
493
|
+
enhanced_messages[0].content += "\n\n**重要**:你必须返回有效的 JSON 格式,不要包含任何其他文本。"
|
|
494
|
+
|
|
495
|
+
response = await self.chat(enhanced_messages)
|
|
496
|
+
content = response.content.strip()
|
|
497
|
+
|
|
498
|
+
# 移除 markdown 代码块标记
|
|
499
|
+
if content.startswith("```json"):
|
|
500
|
+
content = content[7:]
|
|
501
|
+
if content.startswith("```"):
|
|
502
|
+
content = content[3:]
|
|
503
|
+
if content.endswith("```"):
|
|
504
|
+
content = content[:-3]
|
|
505
|
+
content = content.strip()
|
|
506
|
+
|
|
507
|
+
try:
|
|
508
|
+
result = json.loads(content)
|
|
509
|
+
except json.JSONDecodeError as exc:
|
|
510
|
+
raise ModelInvocationError(
|
|
511
|
+
f"Failed to parse JSON from LLM output: {exc}\nOutput: {content[:200]}"
|
|
512
|
+
) from exc
|
|
513
|
+
|
|
514
|
+
if not isinstance(result, dict):
|
|
515
|
+
raise ModelInvocationError(f"LLM output is not a JSON object: {type(result)}")
|
|
516
|
+
|
|
517
|
+
if schema is not None:
|
|
518
|
+
from agent_core.errors import ModelOutputValidationError
|
|
519
|
+
from agent_core.model.structured import validate_json_schema
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
validate_json_schema(result, schema)
|
|
523
|
+
except ModelOutputValidationError as exc:
|
|
524
|
+
raise ModelInvocationError(f"LLM JSON 输出不符合 schema:{exc}") from exc
|
|
525
|
+
|
|
526
|
+
return {str(key): value for key, value in result.items()}
|