Merge pull request #833 from SuanmoSuanyangTechnology/release/v0.2.10

Release/v0.2.10
This commit is contained in:
Ke Sun
2026-04-08 21:45:35 +08:00
committed by GitHub
89 changed files with 1712 additions and 858 deletions

View File

@@ -14,6 +14,7 @@ from typing import Any, AsyncGenerator, Dict, List, Optional, Sequence
from langchain.agents import create_agent
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.tools import BaseTool
from langgraph.errors import GraphRecursionError
from app.core.logging_config import get_business_logger
from app.core.models import RedBearLLM, RedBearModelConfig
@@ -377,7 +378,7 @@ class LangChainAgent:
{"messages": messages},
config={"recursion_limit": self.max_iterations}
)
except RecursionError as e:
except (RecursionError, GraphRecursionError) as e:
logger.warning(
f"Agent 达到最大迭代次数限制 ({self.max_iterations}),可能存在工具调用循环",
extra={"error": str(e)}
@@ -612,6 +613,12 @@ class LangChainAgent:
yield stream_total_tokens
break
except GraphRecursionError:
logger.warning(
f"Agent 达到最大迭代次数限制 ({self.max_iterations}),模型可能不支持正确的工具调用停止判断"
)
if not full_content:
yield "抱歉,我在处理您的请求时遇到了问题(已达最大处理步骤限制)。请尝试简化问题或更换模型后重试。"
except Exception as e:
logger.error(f"Agent astream_events 失败: {str(e)}", exc_info=True)
raise

View File

@@ -19,6 +19,7 @@ class BizCode(IntEnum):
TENANT_NOT_FOUND = 3002
WORKSPACE_NO_ACCESS = 3003
WORKSPACE_INVITE_NOT_FOUND = 3004
WORKSPACE_ACCESS_DENIED = 3005
# API Key 管理3xxx
API_KEY_NOT_FOUND = 3007
API_KEY_DUPLICATE_NAME = 3008
@@ -113,6 +114,8 @@ HTTP_MAPPING = {
BizCode.FORBIDDEN: 403,
BizCode.TENANT_NOT_FOUND: 400,
BizCode.WORKSPACE_NO_ACCESS: 403,
BizCode.WORKSPACE_INVITE_NOT_FOUND: 400,
BizCode.WORKSPACE_ACCESS_DENIED: 403,
BizCode.NOT_FOUND: 400,
BizCode.USER_NOT_FOUND: 200,
BizCode.WORKSPACE_NOT_FOUND: 400,

View File

@@ -79,8 +79,10 @@ class RedBearModelFactory:
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
if is_streaming:
model_kwargs["enable_thinking"] = config.deep_thinking
if config.deep_thinking and config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
if config.deep_thinking:
model_kwargs["incremental_output"] = True
if config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
else:
model_kwargs["enable_thinking"] = False
params["model_kwargs"] = model_kwargs
@@ -110,7 +112,7 @@ class RedBearModelFactory:
params["stream_usage"] = True
# 深度思考模式
is_streaming = bool(config.extra_params.get("streaming"))
if is_streaming:
if is_streaming and not config.is_omni:
if provider == ModelProvider.VOLCANO:
# 火山引擎深度思考仅流式调用支持,非流式时不传 thinking 参数
thinking_config: Dict[str, Any] = {
@@ -140,8 +142,10 @@ class RedBearModelFactory:
model_kwargs: Dict[str, Any] = config.extra_params.get("model_kwargs", {})
if is_streaming:
model_kwargs["enable_thinking"] = config.deep_thinking
if config.deep_thinking and config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
if config.deep_thinking:
model_kwargs["incremental_output"] = True
if config.thinking_budget_tokens:
model_kwargs["thinking_budget"] = config.thinking_budget_tokens
else:
model_kwargs["enable_thinking"] = False
params["model_kwargs"] = model_kwargs

View File

@@ -1,5 +1,5 @@
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List, Union
from langchain_core.embeddings import Embeddings
from app.core.models.base import RedBearModelConfig, get_provider_embedding_class, RedBearModelFactory
@@ -22,7 +22,8 @@ class RedBearEmbeddings(Embeddings):
self._model = self._create_model(config)
self._client = None
def _create_model(self, config: RedBearModelConfig) -> Embeddings:
@staticmethod
def _create_model(config: RedBearModelConfig) -> Embeddings:
"""根据配置创建 LangChain 模型"""
embedding_class = get_provider_embedding_class(config.provider)
provider = config.provider.lower()
@@ -36,6 +37,8 @@ class RedBearEmbeddings(Embeddings):
"api_key": config.api_key,
"timeout": httpx.Timeout(timeout=config.timeout, connect=60.0),
"max_retries": config.max_retries,
"check_embedding_ctx_length": False,
"encoding_format": "float"
}
elif provider == ModelProvider.DASHSCOPE:
params = {

View File

@@ -803,7 +803,6 @@ models:
- vision
- video
- audio
- thinking
is_omni: true
tags:
- 大语言模型

View File

@@ -131,7 +131,7 @@ class DifyConverter(BaseConverter):
selector = var_selector.split('.')
if len(selector) not in [2, 3] and var_selector != "context":
raise Exception(f"invalid variable selector: {var_selector}")
if len(selector) == 3:
if len(selector) == 3 and selector[0] in ("conversation", "sys"):
selector = selector[1:]
if selector[0] == "conversation":
selector[0] = "conv"
@@ -483,11 +483,11 @@ class DifyConverter(BaseConverter):
node_data = node["data"]
result = IterationNodeConfig.model_construct(
input=self._process_list_variable_literal(node_data["iterator_selector"]),
parallel=node_data["is_parallel"],
parallel_count=node_data["parallel_nums"],
parallel=node_data.get("is_parallel", False),
parallel_count=node_data.get("parallel_nums", 4),
output=self._process_list_variable_literal(node_data["output_selector"]),
output_type=self.variable_type_map(node_data.get("output_type")),
flatten=node_data["flatten_output"],
flatten=node_data.get("flatten_output", False),
).model_dump()
self.config_validate(node["id"], node["data"]["title"], IterationNodeConfig, result)
@@ -496,7 +496,23 @@ class DifyConverter(BaseConverter):
def convert_assigner_node_config(self, node: dict) -> dict:
node_data = node["data"]
assignments = []
for assignment in node_data["items"]:
# Support both formats:
# 1. New format: node_data["items"] list
# 2. Flat format: assigned_variable_selector + input_variable_selector + write_mode
if "items" in node_data:
raw_items = node_data["items"]
elif "assigned_variable_selector" in node_data and "input_variable_selector" in node_data:
raw_items = [{
"variable_selector": node_data["assigned_variable_selector"],
"value": node_data["input_variable_selector"],
"input_type": ValueInputType.VARIABLE,
"operation": node_data.get("write_mode", "over-write"),
}]
else:
raw_items = []
for assignment in raw_items:
if assignment.get("operation") is None or assignment.get("value") is None:
continue
assignments.append(

View File

@@ -1,5 +1,5 @@
from typing import Any
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from app.core.workflow.nodes.base_config import BaseNodeConfig
from app.core.workflow.nodes.enums import ComparisonOperator
@@ -31,6 +31,11 @@ class ExtractConfig(BaseModel):
enabled: bool = False
serial: str = "1" # 1-based index string, e.g. "1" = first
@field_validator("serial", mode="before")
@classmethod
def coerce_serial(cls, v):
return str(v)
class ListOperatorNodeConfig(BaseNodeConfig):
"""

View File

@@ -11,7 +11,7 @@ from app.core.workflow.variable.base_variable import VariableType
logger = logging.getLogger(__name__)
# File object fields that hold string values
_FILE_STRING_KEYS = {"name", "extension", "mime_type", "url", "transfer_method", "origin_file_type", "file_id"}
_FILE_STRING_KEYS = {"type", "name", "url", "extension", "mime_type", "transfer_method", "origin_file_type", "file_id"}
_FILE_NUMBER_KEYS = {"size"}
@@ -52,7 +52,7 @@ class ListOperatorNode(BaseNode):
result = [result[idx]]
# 3. Order
if cfg.order_by.enabled and cfg.order_by.key:
if cfg.order_by.enabled:
reverse = cfg.order_by.value == "desc"
key_fn = self._make_sort_key(cfg.order_by.key)
result = sorted(result, key=key_fn, reverse=reverse)
@@ -100,10 +100,17 @@ class ListOperatorNode(BaseNode):
else:
left = item # primitive array: compare element directly
# Determine if this field should be compared as a string
is_string_field = isinstance(item, dict) and cond.key in _FILE_STRING_KEYS
# Numeric operators
if op == ComparisonOperator.EQ:
if is_string_field:
return str(left) == str(value)
return self._safe_num(left) == self._safe_num(value)
if op == ComparisonOperator.NE:
if is_string_field:
return str(left) != str(value)
return self._safe_num(left) != self._safe_num(value)
if op == ComparisonOperator.LT:
return self._safe_num(left) < self._safe_num(value)

View File

@@ -246,7 +246,10 @@ class LLMNode(BaseNode):
logger.info(f"节点 {self.node_id} LLM 调用完成,输出长度: {len(content)}")
# 返回 AIMessage包含响应元数据
return AIMessage(content=content, response_metadata=response.response_metadata)
return AIMessage(content=content, response_metadata={
**response.response_metadata,
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
})
def _extract_input(self, state: WorkflowState, variable_pool: VariablePool) -> dict[str, Any]:
"""提取输入数据(用于记录)"""
@@ -305,15 +308,16 @@ class LLMNode(BaseNode):
# 调用 LLM流式支持字符串或消息列表
last_meta_data = {}
last_usage_metadata = {}
async for chunk in llm.astream(self.messages):
# 提取内容
if hasattr(chunk, 'content'):
content = self.process_model_output(chunk.content)
else:
content = str(chunk)
if hasattr(chunk, 'response_metadata'):
if chunk.response_metadata:
last_meta_data = chunk.response_metadata
if hasattr(chunk, 'response_metadata') and chunk.response_metadata:
last_meta_data = chunk.response_metadata
if hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
last_usage_metadata = chunk.usage_metadata
# 只有当内容不为空时才处理
if content:
@@ -336,7 +340,10 @@ class LLMNode(BaseNode):
# 构建完整的 AIMessage包含元数据
final_message = AIMessage(
content=full_response,
response_metadata=last_meta_data
response_metadata={
**last_meta_data,
"token_usage": last_usage_metadata or last_meta_data.get('token_usage')
}
)
# yield 完成标记

View File

@@ -12,7 +12,7 @@ from app.core.workflow.engine.state_manager import WorkflowState
from app.core.workflow.engine.variable_pool import VariablePool
from app.core.workflow.nodes.base_node import BaseNode
from app.core.workflow.nodes.parameter_extractor.config import ParameterExtractorNodeConfig
from app.core.workflow.variable.base_variable import VariableType
from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE
from app.db import get_db_read
from app.models import ModelType
from app.services.model_service import ModelConfigService
@@ -45,6 +45,12 @@ class ParameterExtractorNode(BaseNode):
"model_id": str(self.typed_config.model_id),
}
def _extract_output(self, business_result: Any) -> Any:
final_output = {}
for param in self.typed_config.params:
final_output[param.name] = business_result.get(param.name) or DEFAULT_VALUE(self.output_types[param.name])
return final_output
def _output_types(self) -> dict[str, VariableType]:
outputs = {}
for param in self.typed_config.params:
@@ -202,7 +208,10 @@ class ParameterExtractorNode(BaseNode):
])
model_resp = await llm.ainvoke(messages)
self.response_metadata = model_resp.response_metadata
self.response_metadata = {
**model_resp.response_metadata,
"token_usage": getattr(model_resp, 'usage_metadata', None) or model_resp.response_metadata.get('token_usage')
}
model_message = self.process_model_output(model_resp.content)
result = json_repair.repair_json(model_message, return_objects=True)
logger.info(f"node: {self.node_id} get params:{result}")

View File

@@ -136,7 +136,10 @@ class QuestionClassifierNode(BaseNode):
response = await llm.ainvoke(messages)
result = self.process_model_output(response.content)
self.response_metadata = response.response_metadata
self.response_metadata = {
**response.response_metadata,
"token_usage": getattr(response, 'usage_metadata', None) or response.response_metadata.get('token_usage')
}
if result in category_names:
category = result

View File

@@ -91,7 +91,7 @@ async def fetch_remote_file_meta(
"""
import httpx
name = size = mime_type = extension = None
name = extension = None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.head(url, follow_redirects=True)