Replace temperature with reasoning effort and retry in-stream errors
- Providers get a default reasoning effort (low..max, empty = not sent); chat can override it per message. OpenAI sends reasoning_effort, Claude sends output_config.effort with adaptive thinking, and thinking blocks are replayed during the tool loop. - Show a thinking indicator while waiting for model output. - Surface in-stream upstream errors (e.g. overloaded) instead of breaking the SSE stream, and retry them up to 3 times when nothing has been emitted yet.
This commit is contained in:
1 parent
5db3ead883
commit
b549f3f441
9 files changed
+190
-51
No files matched your search
@@ -2,14 +2,16 @@
|
||||
|
||||
消息使用与协议无关的格式(与 ChatMessage.content 一致,外加 role):
|
||||
{"role": "user", "text"}
|
||||
{"role": "assistant", "text", "tool_calls": [{"id", "name", "arguments"}]}
|
||||
{"role": "assistant", "text", "tool_calls": [{"id", "name", "arguments"}], "thinking_blocks"?: [...]}
|
||||
{"role": "tool", "tool_call_id", "name", "result"}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
import anthropic
|
||||
import openai
|
||||
@@ -17,9 +19,17 @@ import openai
|
||||
from alphapulse.core.crypto import decrypt
|
||||
from alphapulse.models import AiProvider
|
||||
|
||||
TIMEOUT_SECONDS = 120
|
||||
# 高思考强度时 OpenAI 格式在推理期间不推送任何内容,读超时要留足
|
||||
TIMEOUT_SECONDS = 600
|
||||
# 思考强度:两种协议共有的取值。OpenAI 另有 none/minimal,但实测经 Sub2API 会卡住后 502,不提供
|
||||
Effort = Literal["low", "medium", "high", "xhigh", "max"]
|
||||
# SDK 自带重试只针对连接错误、429 和 5xx
|
||||
MAX_RETRIES = 2
|
||||
# 流内错误(上游过载等)SDK 不重试,由这里重试;只在还没有输出内容时重试
|
||||
STREAM_RETRIES = 3
|
||||
STREAM_RETRY_DELAY_SECONDS = 2
|
||||
|
||||
logger = logging.getLogger("alphapulse.ai.llm")
|
||||
|
||||
|
||||
class LlmError(Exception):
|
||||
@@ -39,6 +49,8 @@ class Completion:
|
||||
text: str = ""
|
||||
tool_calls: list[dict] = field(default_factory=list)
|
||||
usage: dict = field(default_factory=dict)
|
||||
# Anthropic 的 thinking / redacted_thinking 块:工具调用循环中必须原样回传,否则接口报错
|
||||
thinking_blocks: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -46,27 +58,60 @@ class TextDelta:
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThinkingStarted:
|
||||
"""模型开始思考(只有 Claude 格式会推送);前端据此显示「思考中」"""
|
||||
|
||||
|
||||
def api_root(base_url: str) -> str:
|
||||
"""用户可能填 https://host 或 https://host/v1,统一成不带 /v1 的根地址"""
|
||||
root = base_url.rstrip("/")
|
||||
return root.removesuffix("/v1")
|
||||
|
||||
|
||||
async def stream(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec]) -> AsyncIterator[TextDelta | Completion]:
|
||||
"""先逐段产出 TextDelta,最后产出一个 Completion"""
|
||||
try:
|
||||
if provider.protocol == "openai":
|
||||
async for event in _stream_openai(provider, system, messages, tools):
|
||||
async def stream(
|
||||
provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None = None,
|
||||
) -> AsyncIterator[TextDelta | ThinkingStarted | Completion]:
|
||||
"""先逐段产出 TextDelta / ThinkingStarted,最后产出一个 Completion。effort 为空表示不传,由模型决定"""
|
||||
for attempt in range(STREAM_RETRIES + 1):
|
||||
output = False
|
||||
try:
|
||||
if provider.protocol == "openai":
|
||||
events = _stream_openai(provider, system, messages, tools, effort)
|
||||
else:
|
||||
events = _stream_anthropic(provider, system, messages, tools, effort)
|
||||
async for event in events:
|
||||
output = True
|
||||
yield event
|
||||
else:
|
||||
async for event in _stream_anthropic(provider, system, messages, tools):
|
||||
yield event
|
||||
except (openai.APIStatusError, anthropic.APIStatusError) as e:
|
||||
raise LlmError(f"模型接口返回 {e.status_code}:{e.message}") from e
|
||||
except (openai.APITimeoutError, anthropic.APITimeoutError) as e:
|
||||
raise LlmError(f"模型接口超时({TIMEOUT_SECONDS}s)") from e
|
||||
except (openai.APIConnectionError, anthropic.APIConnectionError) as e:
|
||||
raise LlmError(f"无法连接模型接口:{e}") from e
|
||||
return
|
||||
except (openai.APIError, anthropic.APIError) as e:
|
||||
# 已经推送过内容再重试会重复输出,只重试还没有输出时的流内错误
|
||||
if output or attempt == STREAM_RETRIES or not _in_stream_error(e):
|
||||
raise _llm_error(e) from e
|
||||
logger.warning("模型流内出错,%ss 后第 %s 次重试:%s", STREAM_RETRY_DELAY_SECONDS, attempt + 1, _llm_error(e))
|
||||
await asyncio.sleep(STREAM_RETRY_DELAY_SECONDS)
|
||||
|
||||
|
||||
def _in_stream_error(e: Exception) -> bool:
|
||||
"""连接建立后上游在流内返回的错误事件(如 overloaded)。
|
||||
OpenAI SDK 抛 APIError 基类;Anthropic SDK 抛 APIStatusError,状态码是流本身的 200"""
|
||||
if isinstance(e, openai.APIError):
|
||||
return not isinstance(e, (openai.APIStatusError, openai.APIConnectionError))
|
||||
return isinstance(e, anthropic.APIStatusError) and e.status_code == 200
|
||||
|
||||
|
||||
def _llm_error(e: Exception) -> LlmError:
|
||||
if _in_stream_error(e):
|
||||
error = e.body.get("error") if isinstance(e.body, dict) else None
|
||||
message = error.get("message") if isinstance(error, dict) else None
|
||||
return LlmError(f"模型接口出错:{message or e.message}")
|
||||
if isinstance(e, (openai.APIStatusError, anthropic.APIStatusError)):
|
||||
return LlmError(f"模型接口返回 {e.status_code}:{e.message}")
|
||||
if isinstance(e, (openai.APITimeoutError, anthropic.APITimeoutError)):
|
||||
return LlmError(f"模型接口超时({TIMEOUT_SECONDS}s)")
|
||||
if isinstance(e, (openai.APIConnectionError, anthropic.APIConnectionError)):
|
||||
return LlmError(f"无法连接模型接口:{e}")
|
||||
return LlmError(f"模型接口出错:{e}")
|
||||
|
||||
|
||||
# ---------- OpenAI ----------
|
||||
@@ -92,7 +137,7 @@ def _openai_messages(system: str, messages: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
async def _stream_openai(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec]) -> AsyncIterator[TextDelta | Completion]:
|
||||
async def _stream_openai(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None) -> AsyncIterator[TextDelta | Completion]:
|
||||
client = openai.AsyncOpenAI(
|
||||
api_key=decrypt(provider.api_key_encrypted), base_url=f"{api_root(provider.base_url)}/v1",
|
||||
timeout=TIMEOUT_SECONDS, max_retries=MAX_RETRIES,
|
||||
@@ -100,8 +145,8 @@ async def _stream_openai(provider: AiProvider, system: str, messages: list[dict]
|
||||
kwargs: dict[str, Any] = {}
|
||||
if tools:
|
||||
kwargs["tools"] = [{"type": "function", "function": {"name": t.name, "description": t.description, "parameters": t.parameters}} for t in tools]
|
||||
if provider.temperature is not None:
|
||||
kwargs["temperature"] = provider.temperature
|
||||
if effort:
|
||||
kwargs["reasoning_effort"] = effort
|
||||
result = Completion()
|
||||
# 按 index 拼接分片到达的工具调用
|
||||
calls: dict[int, dict] = {}
|
||||
@@ -139,7 +184,10 @@ def _anthropic_messages(messages: list[dict]) -> list[dict]:
|
||||
if m["role"] == "user":
|
||||
out.append({"role": "user", "content": m["text"]})
|
||||
elif m["role"] == "assistant":
|
||||
blocks: list[dict] = [{"type": "text", "text": m["text"]}] if m["text"] else []
|
||||
# 思考块必须排在最前,原样回传
|
||||
blocks: list[dict] = list(m.get("thinking_blocks") or [])
|
||||
if m["text"]:
|
||||
blocks.append({"type": "text", "text": m["text"]})
|
||||
blocks += [{"type": "tool_use", "id": c["id"], "name": c["name"], "input": c["arguments"]} for c in m.get("tool_calls") or []]
|
||||
# 空的 assistant 消息会被接口拒绝
|
||||
if blocks:
|
||||
@@ -154,7 +202,7 @@ def _anthropic_messages(messages: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
async def _stream_anthropic(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec]) -> AsyncIterator[TextDelta | Completion]:
|
||||
async def _stream_anthropic(provider: AiProvider, system: str, messages: list[dict], tools: list[ToolSpec], effort: Effort | None) -> AsyncIterator[TextDelta | ThinkingStarted | Completion]:
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=decrypt(provider.api_key_encrypted), base_url=api_root(provider.base_url),
|
||||
timeout=TIMEOUT_SECONDS, max_retries=MAX_RETRIES,
|
||||
@@ -162,11 +210,13 @@ async def _stream_anthropic(provider: AiProvider, system: str, messages: list[di
|
||||
kwargs: dict[str, Any] = {}
|
||||
if tools:
|
||||
kwargs["tools"] = [{"name": t.name, "description": t.description, "input_schema": t.parameters} for t in tools]
|
||||
if provider.temperature is not None:
|
||||
# 当前 SDK 不再提供 temperature 参数,按原始字段透传
|
||||
kwargs["extra_body"] = {"temperature": provider.temperature}
|
||||
if effort:
|
||||
# 思考强度通过 output_config.effort 控制,配合自适应思考(由模型决定是否、思考多久)
|
||||
kwargs["output_config"] = {"effort": effort}
|
||||
kwargs["thinking"] = {"type": "adaptive"}
|
||||
result = Completion()
|
||||
blocks: dict[int, dict] = {}
|
||||
thinking: dict[int, dict] = {}
|
||||
async with client:
|
||||
events = await client.messages.create(
|
||||
model=provider.model, system=system, messages=_anthropic_messages(messages), max_tokens=provider.max_tokens,
|
||||
@@ -177,15 +227,26 @@ async def _stream_anthropic(provider: AiProvider, system: str, messages: list[di
|
||||
result.usage["input_tokens"] = event.message.usage.input_tokens
|
||||
elif event.type == "content_block_start" and event.content_block.type == "tool_use":
|
||||
blocks[event.index] = {"id": event.content_block.id, "name": event.content_block.name, "arguments": ""}
|
||||
elif event.type == "content_block_start" and event.content_block.type == "thinking":
|
||||
thinking[event.index] = {"type": "thinking", "thinking": event.content_block.thinking, "signature": event.content_block.signature}
|
||||
yield ThinkingStarted()
|
||||
elif event.type == "content_block_start" and event.content_block.type == "redacted_thinking":
|
||||
thinking[event.index] = {"type": "redacted_thinking", "data": event.content_block.data}
|
||||
yield ThinkingStarted()
|
||||
elif event.type == "content_block_delta":
|
||||
if event.delta.type == "text_delta":
|
||||
result.text += event.delta.text
|
||||
yield TextDelta(event.delta.text)
|
||||
elif event.delta.type == "input_json_delta":
|
||||
blocks[event.index]["arguments"] += event.delta.partial_json
|
||||
elif event.delta.type == "thinking_delta":
|
||||
thinking[event.index]["thinking"] += event.delta.thinking
|
||||
elif event.delta.type == "signature_delta":
|
||||
thinking[event.index]["signature"] += event.delta.signature
|
||||
elif event.type == "message_delta":
|
||||
result.usage["output_tokens"] = event.usage.output_tokens
|
||||
result.tool_calls = [_parse_call(b["id"], b["name"], b["arguments"]) for _, b in sorted(blocks.items())]
|
||||
result.thinking_blocks = [b for _, b in sorted(thinking.items())]
|
||||
yield result
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ class SessionOut(BaseModel):
|
||||
class SendIn(BaseModel):
|
||||
text: Annotated[str, Field(min_length=1, max_length=20000)]
|
||||
provider_id: int | None = None
|
||||
# 本轮思考强度;为空时用模型配置里的默认值
|
||||
reasoning_effort: llm.Effort | None = None
|
||||
|
||||
|
||||
class ResetIn(BaseModel):
|
||||
@@ -121,7 +123,7 @@ async def get_chat_session(session: Annotated[AsyncSession, Depends(get_session)
|
||||
|
||||
@router.post("/messages", response_class=EventSourceResponse)
|
||||
async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
|
||||
"""事件:prompt、delta、tool_call、tool_result、skill_draft、context_updated、usage、error、done"""
|
||||
"""事件:prompt、thinking、delta、tool_call、tool_result、skill_draft、context_updated、usage、error、done"""
|
||||
if generating.locked():
|
||||
yield event("error", {"message": "上一轮还在生成,请等待完成或先停止"})
|
||||
return
|
||||
@@ -148,14 +150,17 @@ async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
|
||||
await save_messages(ChatMessage(session_id=current.id, role="user", content={"text": body.text}))
|
||||
|
||||
specs = tool_specs()
|
||||
effort = body.reasoning_effort or provider.reasoning_effort
|
||||
for round_no in range(MAX_TOOL_ROUNDS + 1):
|
||||
text = ""
|
||||
completion: llm.Completion | None = None
|
||||
try:
|
||||
async for item in llm.stream(provider, system, messages, specs if round_no < MAX_TOOL_ROUNDS else []):
|
||||
async for item in llm.stream(provider, system, messages, specs if round_no < MAX_TOOL_ROUNDS else [], effort):
|
||||
if isinstance(item, llm.TextDelta):
|
||||
text += item.text
|
||||
yield event("delta", {"text": item.text})
|
||||
elif isinstance(item, llm.ThinkingStarted):
|
||||
yield event("thinking", {})
|
||||
else:
|
||||
completion = item
|
||||
except llm.LlmError as e:
|
||||
@@ -170,6 +175,8 @@ async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
|
||||
raise
|
||||
|
||||
assistant = {"text": completion.text, "tool_calls": completion.tool_calls}
|
||||
if completion.thinking_blocks:
|
||||
assistant["thinking_blocks"] = completion.thinking_blocks
|
||||
await save_messages(ChatMessage(session_id=current.id, role="assistant", content=assistant, provider_id=provider.id, usage=completion.usage))
|
||||
messages.append({"role": "assistant", **assistant})
|
||||
yield event("usage", completion.usage)
|
||||
|
||||
@@ -30,7 +30,7 @@ class ProviderOut(BaseModel):
|
||||
model: str
|
||||
max_tokens: int
|
||||
context_window: int
|
||||
temperature: float | None
|
||||
reasoning_effort: llm.Effort | None
|
||||
is_default: bool
|
||||
updated_at: datetime
|
||||
|
||||
@@ -43,7 +43,7 @@ class ProviderIn(BaseModel):
|
||||
model: Annotated[str, Field(min_length=1, max_length=128)]
|
||||
max_tokens: Annotated[int, Field(ge=256, le=128000)] = 8192
|
||||
context_window: Annotated[int, Field(ge=4096, le=2000000)] = 200000
|
||||
temperature: Annotated[float | None, Field(ge=0, le=2)] = None
|
||||
reasoning_effort: llm.Effort | None = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ class ProviderPatch(BaseModel):
|
||||
model: Annotated[str | None, Field(min_length=1, max_length=128)] = None
|
||||
max_tokens: Annotated[int | None, Field(ge=256, le=128000)] = None
|
||||
context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None
|
||||
temperature: Annotated[float | None, Field(ge=0, le=2)] = None
|
||||
# 显式传 null 表示不传思考强度
|
||||
reasoning_effort: llm.Effort | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
@@ -111,9 +112,9 @@ async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Dep
|
||||
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
|
||||
provider = await get_provider(session, provider_id)
|
||||
values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"})
|
||||
# temperature 显式传 null 表示清除,其他字段为 null 时忽略
|
||||
# reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略
|
||||
for field, value in values.items():
|
||||
if value is not None or field == "temperature":
|
||||
if value is not None or field == "reasoning_effort":
|
||||
setattr(provider, field, value)
|
||||
if body.base_url is not None:
|
||||
provider.base_url = str(body.base_url)
|
||||
@@ -147,7 +148,7 @@ async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depen
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completion = None
|
||||
async for event in llm.stream(provider, "只回复 OK。", [{"role": "user", "text": "ping"}], []):
|
||||
async for event in llm.stream(provider, "只回复 OK。", [{"role": "user", "text": "ping"}], [], provider.reasoning_effort):
|
||||
if isinstance(event, llm.Completion):
|
||||
completion = event
|
||||
except llm.LlmError as e:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -23,7 +23,8 @@ class AiProvider(Base):
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=8192)
|
||||
# 上下文上限(token),超过时拒绝发送而不是静默截断
|
||||
context_window: Mapped[int] = mapped_column(Integer, default=200000)
|
||||
temperature: Mapped[float | None] = mapped_column(Float)
|
||||
# 默认思考强度;为空表示不传,由模型决定。Chat 可按轮覆盖
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(8))
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
Reference in new issue
Block a user