Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,864 @@
"""
智能体服务
"""
import logging
import time
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ai_platform.models import (
Agent, AgentConversation, AgentMessage, LLMModel,
)
from .llm_service import LLMService
logger = logging.getLogger(__name__)
class AgentService:
"""
智能体服务
管理智能体的对话和推理
"""
def __init__(self, db: Optional[AsyncSession] = None):
self._db = db
self.llm_service = LLMService(db)
async def chat(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
application_id: str = None,
form_code: str = None,
attachments: List[Dict] = None,
):
"""
与智能体对话(流式)
Args:
agent: 智能体
conversation: 对话
user_message: 用户消息
application_id: 子应用 ID(用于表单创建等场景)
form_code: 表单编码(从表单列表调用时传入)
attachments: 附件列表 [{id, type, name, url, mime_type, size}]
Yields:
事件流
"""
if agent.mode == 'autonomous':
async for event in self._chat_autonomous(agent, conversation, user_message, attachments):
yield event
elif agent.mode == 'dialog_flow':
async for event in self._chat_dialog_flow(agent, conversation, user_message, application_id, form_code, attachments):
yield event
else:
yield {'type': 'error', 'content': f'不支持的模式: {agent.mode}'}
async def _chat_autonomous(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
attachments: List[Dict] = None,
):
"""
自主规划模式对话
优先使用原生 Function Calling,如果模型不支持则回退到 ReAct 模式
Yields:
流式事件
"""
# 检查模型是否支持 Function Calling
supports_function_call = await self._check_model_supports_function_call(agent.model_id)
if supports_function_call:
async for event in self._chat_autonomous_function_calling(agent, conversation, user_message, attachments):
yield event
else:
async for event in self._chat_autonomous_react(agent, conversation, user_message, attachments):
yield event
async def _check_model_supports_function_call(self, model_id) -> bool:
"""检查模型是否支持 Function Calling"""
if not self._db or not model_id:
return False
result = await self._db.execute(
select(LLMModel).where(LLMModel.id == model_id, LLMModel.is_deleted == False)
)
model = result.scalar_one_or_none()
return model.supports_function_call if model else False
async def _chat_autonomous_function_calling(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
attachments: List[Dict] = None,
):
"""
自主规划模式对话(支持多模态附件)
Yields:
流式事件
"""
from ai_platform.providers.base import LLMMessage
start_time = time.time()
if not self._db:
yield {'type': 'error', 'content': '数据库会话未初始化'}
return
# 创建用户消息(包含附件)
# 附件格式: [{file_id, type, name, mime_type, size}]
stored_attachments = []
if attachments:
for att in attachments:
stored_attachments.append({
'file_id': att.get('file_id'),
'type': att.get('type', 'file'),
'name': att.get('name', ''),
'mime_type': att.get('mime_type', ''),
'size': att.get('size', 0),
})
user_msg = AgentMessage(
conversation_id=conversation.id,
role='user',
content=user_message,
attachments=stored_attachments,
status='completed',
)
self._db.add(user_msg)
await self._db.flush()
# 创建助手消息(pending 状态)
assistant_msg = AgentMessage(
conversation_id=conversation.id,
role='assistant',
content='',
status='pending',
)
self._db.add(assistant_msg)
await self._db.flush()
yield {
'type': 'start',
'conversation_id': str(conversation.id),
'message_id': str(assistant_msg.id),
}
try:
# 构建系统提示词
system_prompt = self._build_system_prompt_simple(agent)
# 标注直接回复检查(高相似度时跳过 LLM,直接返回标注答案)
annotation_reply = await self._check_annotation_direct_reply(agent, user_message)
if annotation_reply:
final_answer = annotation_reply['answer']
elapsed_time = int((time.time() - start_time) * 1000)
assistant_msg.content = final_answer
assistant_msg.status = 'completed'
assistant_msg.elapsed_time = elapsed_time
yield {
'type': 'annotation_reply',
'content': f"标注直接回复 (相似度: {annotation_reply['score']:.2f})",
}
yield {
'type': 'answer',
'content': final_answer,
}
conversation.message_count = (conversation.message_count or 0) + 2
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
self._generate_title(conversation, user_message)
agent.message_count = (agent.message_count or 0) + 2
await self._db.commit()
yield {
'type': 'complete',
'message_id': str(assistant_msg.id),
'conversation_id': str(conversation.id),
'tokens_used': 0,
'elapsed_time': elapsed_time,
}
return
# 知识库检索增强(RAG
knowledge_context = await self._retrieve_knowledge_context(agent, user_message)
if knowledge_context:
system_prompt = self._inject_knowledge_context(system_prompt, knowledge_context)
yield {
'type': 'knowledge_retrieval',
'content': knowledge_context['summary'],
'result_count': knowledge_context['result_count'],
}
# 获取对话历史
history = await self._get_conversation_history(conversation, limit=10)
# 构建当前用户消息内容(支持多模态)
user_content = LLMMessage.create_multimodal_content(user_message, attachments)
history.append({'role': 'user', 'content': user_content})
# 构建消息
messages = [{'role': 'system', 'content': system_prompt}] + history
# 检查是否启用流式输出
enable_streaming = getattr(agent, 'enable_streaming', True)
total_tokens = 0
final_answer = ''
if enable_streaming:
# 流式输出
accumulated_content = ''
async for chunk in self.llm_service.chat_stream(
model_id=str(agent.model_id),
messages=messages,
temperature=agent.temperature or 0.7,
max_tokens=agent.max_tokens or 2048,
):
if chunk.content:
accumulated_content += chunk.content
yield {
'type': 'llm_chunk',
'content': chunk.content,
'accumulated_content': accumulated_content,
}
if chunk.is_finished:
total_tokens = chunk.total_tokens
final_answer = accumulated_content
else:
# 非流式输出
response = await self.llm_service.chat_async(
model_id=str(agent.model_id),
messages=messages,
temperature=agent.temperature or 0.7,
max_tokens=agent.max_tokens or 2048,
)
total_tokens = response.total_tokens
final_answer = response.content
yield {
'type': 'answer',
'content': final_answer,
}
# 更新助手消息
elapsed_time = int((time.time() - start_time) * 1000)
assistant_msg.content = final_answer
assistant_msg.status = 'completed'
assistant_msg.total_tokens = total_tokens
assistant_msg.elapsed_time = elapsed_time
# 更新对话统计
conversation.message_count = (conversation.message_count or 0) + 2
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
# 自动生成标题(第一次对话时,且标题为默认值)
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
self._generate_title(conversation, user_message)
# 更新智能体统计
agent.message_count = (agent.message_count or 0) + 2
agent.total_tokens = (agent.total_tokens or 0) + total_tokens
await self._db.commit()
yield {
'type': 'complete',
'message_id': str(assistant_msg.id),
'conversation_id': str(conversation.id),
'tokens_used': total_tokens,
'elapsed_time': elapsed_time,
}
except Exception as e:
logger.exception(f'Agent chat error: {e}')
assistant_msg.status = 'failed'
assistant_msg.error_message = str(e)
await self._db.commit()
yield {
'type': 'error',
'content': str(e),
}
async def _chat_autonomous_react(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
attachments: List[Dict] = None,
):
"""
ReAct 模式(简化版,不使用工具,直接复用 function calling 逻辑)
Yields:
流式事件
"""
# 直接复用简化后的 function calling 逻辑
async for event in self._chat_autonomous_function_calling(agent, conversation, user_message, attachments):
yield event
async def _chat_dialog_flow(
self,
agent: Agent,
conversation: AgentConversation,
user_message: str,
application_id: str = None,
form_code: str = None,
attachments: List[Dict] = None,
):
"""
对话流模式对话
直接复用工作流服务的执行逻辑,透传工作流事件
Args:
agent: 智能体
conversation: 对话
user_message: 用户消息
application_id: 子应用 ID(用于表单创建等场景)
form_code: 表单编码(从表单列表调用时传入)
attachments: 附件列表
Yields:
流式事件
"""
start_time = time.time()
if not self._db:
yield {'type': 'error', 'content': '数据库会话未初始化'}
return
# 创建用户消息(包含附件)
# 附件格式: [{file_id, type, name, mime_type, size}]
stored_attachments = []
if attachments:
for att in attachments:
stored_attachments.append({
'file_id': att.get('file_id'),
'type': att.get('type', 'file'),
'name': att.get('name', ''),
'mime_type': att.get('mime_type', ''),
'size': att.get('size', 0),
})
user_msg = AgentMessage(
conversation_id=conversation.id,
role='user',
content=user_message,
attachments=stored_attachments,
status='completed',
)
self._db.add(user_msg)
await self._db.flush()
# 创建助手消息
assistant_msg = AgentMessage(
conversation_id=conversation.id,
role='assistant',
content='',
status='pending',
)
self._db.add(assistant_msg)
await self._db.flush()
yield {
'type': 'start',
'conversation_id': str(conversation.id),
'message_id': str(assistant_msg.id),
}
try:
from ai_platform.services.workflow_service import AIWorkflowService
workflow_service = AIWorkflowService(self._db)
# 检查是否有进行中的工作流运行
workflow_run_id = None
if conversation.extra_data:
workflow_run_id = conversation.extra_data.get('workflow_run_id')
final_content = ''
is_waiting = False
total_tokens = 0
current_run_id = workflow_run_id
# 获取对话历史(如果启用了记忆功能)
conversation_history = []
if agent.enable_memory:
conversation_history = await self._get_conversation_history(
conversation,
limit=agent.memory_window or 10
)
if workflow_run_id:
# 恢复工作流执行 - 使用异步方法
# 将附件内容(文件解析内容或图片OCR)整合到 user_input 中
enhanced_user_input = user_message
if attachments:
attachment_texts = []
for att in attachments:
text_content = att.get('text_content', '')
if text_content:
file_name = att.get('name', 'unknown')
attachment_texts.append(f"\n[附件: {file_name}]\n{text_content}")
if attachment_texts:
enhanced_user_input = user_message + '\n' + '\n'.join(attachment_texts)
async for event in workflow_service.resume_workflow_stream_async(
run_id=workflow_run_id,
user_input=enhanced_user_input,
conversation_history=conversation_history,
):
event_type = event.get('type')
if event_type == 'answer':
final_content = event.get('content', '')
elif event_type == 'llm_chunk':
accumulated = event.get('accumulated_content', '')
if accumulated:
final_content = accumulated
elif event_type == 'node_complete':
node_type = event.get('node_type')
if node_type == 'llm':
outputs = event.get('outputs', {})
output = outputs.get('output', '')
if output:
final_content = output
elif event_type == 'waiting_input':
is_waiting = True
elif event_type == 'complete':
total_tokens = event.get('total_tokens', 0)
yield event
else:
# 新对话,启动工作流 - 使用异步方法
if not agent.workflow_id:
yield {'type': 'error', 'content': '智能体未配置工作流'}
return
# 构建工作流输入变量
# 将附件内容(文件解析内容或图片OCR)整合到 user_input 中
enhanced_user_input = user_message
if attachments:
attachment_texts = []
for att in attachments:
text_content = att.get('text_content', '')
if text_content:
file_name = att.get('name', 'unknown')
attachment_texts.append(f"\n[附件: {file_name}]\n{text_content}")
if attachment_texts:
enhanced_user_input = user_message + '\n' + '\n'.join(attachment_texts)
workflow_inputs = {'user_input': enhanced_user_input}
if application_id:
workflow_inputs['application_id'] = application_id
if form_code:
workflow_inputs['form_code'] = form_code
# 调试日志
import logging
logger = logging.getLogger(__name__)
logger.info(f"[AgentService] form_code parameter: {form_code}")
logger.info(f"[AgentService] workflow_inputs: {workflow_inputs}")
async for event in workflow_service.run_workflow_stream_async(
workflow_id=str(agent.workflow_id),
inputs=workflow_inputs,
conversation_id=str(conversation.id),
conversation_history=conversation_history,
trigger_type='agent',
):
event_type = event.get('type')
if event_type == 'start':
current_run_id = event.get('run_id')
elif event_type == 'answer':
final_content = event.get('content', '')
elif event_type == 'llm_chunk':
accumulated = event.get('accumulated_content', '')
if accumulated:
final_content = accumulated
elif event_type == 'node_complete':
node_type = event.get('node_type')
if node_type == 'llm':
outputs = event.get('outputs', {})
output = outputs.get('output', '')
if output:
final_content = output
elif event_type == 'waiting_input':
is_waiting = True
# 保存工作流运行 ID 到对话元数据
if current_run_id:
conversation.extra_data = conversation.extra_data or {}
conversation.extra_data['workflow_run_id'] = current_run_id
elif event_type == 'complete':
total_tokens = event.get('total_tokens', 0)
yield event
# 更新助手消息
elapsed_time = int((time.time() - start_time) * 1000)
if final_content:
assistant_msg.content = final_content
assistant_msg.status = 'completed'
assistant_msg.elapsed_time = elapsed_time
assistant_msg.total_tokens = total_tokens
# 更新对话统计
conversation.message_count = (conversation.message_count or 0) + 2
conversation.total_tokens = (conversation.total_tokens or 0) + total_tokens
# 自动生成标题(第一次对话时,且标题为默认值)
if conversation.message_count == 2 and (not conversation.title or conversation.title == "新建对话"):
self._generate_title(conversation, user_message)
# 如果工作流完成(非等待状态),清除对话元数据
if not is_waiting:
if conversation.extra_data:
conversation.extra_data.pop('workflow_run_id', None)
await self._db.commit()
yield {
'type': 'complete',
'elapsed_time': elapsed_time,
'tokens_used': total_tokens,
}
except Exception as e:
logger.exception(f'Dialog flow error: {e}')
assistant_msg.status = 'failed'
assistant_msg.error_message = str(e)
await self._db.commit()
yield {
'type': 'error',
'content': str(e),
}
def _build_system_prompt_simple(self, agent: Agent) -> str:
"""构建简化的系统提示词"""
# 基础提示词
base_prompt = agent.system_prompt
# 如果没有系统提示词,从人设生成
if not base_prompt and agent.persona:
base_prompt = self._generate_prompt_from_persona(agent.persona)
if not base_prompt:
base_prompt = "你是一个智能助手,可以帮助用户完成各种任务。回答要简洁明了,使用中文。"
return base_prompt
def _generate_prompt_from_persona(self, persona: Dict[str, Any]) -> str:
"""从人设配置生成提示词"""
parts = []
if persona.get('role'):
parts.append(persona['role'])
if persona.get('personality'):
personalities = persona['personality']
if isinstance(personalities, list):
parts.append(f"你的性格特点是:{', '.join(personalities)}")
if persona.get('skills'):
skills = persona['skills']
if isinstance(skills, list):
parts.append(f"你擅长:{', '.join(skills)}")
if persona.get('background'):
parts.append(persona['background'])
if persona.get('constraints'):
constraints = persona['constraints']
if isinstance(constraints, list):
parts.append("注意事项:\n" + "\n".join(f"- {c}" for c in constraints))
return "\n\n".join(parts)
async def _get_conversation_history(
self,
conversation: AgentConversation,
limit: int = 10,
) -> List[Dict[str, str]]:
"""获取对话历史"""
if not self._db:
return []
result = await self._db.execute(
select(AgentMessage).where(
AgentMessage.conversation_id == conversation.id,
AgentMessage.is_deleted == False,
AgentMessage.role.in_(['user', 'assistant']),
AgentMessage.status == 'completed'
).order_by(AgentMessage.sys_create_datetime.desc()).limit(limit)
)
messages = result.scalars().all()
history = []
for msg in reversed(list(messages)):
history.append({
'role': msg.role,
'content': msg.content,
})
return history
def _generate_title(self, conversation, first_message: str):
"""自动生成对话标题"""
# 简单截取前 20 个字符作为标题
title = first_message[:20]
if len(first_message) > 20:
title += '...'
conversation.title = title
async def _check_annotation_direct_reply(
self,
agent: Agent,
query: str,
) -> Optional[Dict[str, Any]]:
"""
检查标注直接回复(Dify 风格)
当启用标注回复且匹配到高相似度标注时,直接返回标注答案,跳过 LLM 调用。
Returns:
{'answer': str, 'score': float, 'question': str} 或 None
"""
knowledge_base_ids = getattr(agent, 'knowledge_base_ids', None) or []
if not knowledge_base_ids or not self._db:
return None
knowledge_config = getattr(agent, 'knowledge_config', None) or {}
annotation_reply_enabled = knowledge_config.get('annotation_reply_enabled', False)
if not annotation_reply_enabled:
return None
annotation_threshold = knowledge_config.get('annotation_threshold', 0.9)
try:
from ai_platform.knowledge.services.retrieval_service import RetrievalService
from ai_platform.knowledge.models import KnowledgeBase
from sqlalchemy import select
# 获取第一个知识库的 embedding 配置
kb_result = await self._db.execute(
select(KnowledgeBase).where(
KnowledgeBase.id.in_(knowledge_base_ids),
KnowledgeBase.is_deleted == False,
)
)
first_kb = kb_result.scalars().first()
if not first_kb or not first_kb.embedding_model_id:
return None
service = RetrievalService(self._db)
annotation_results = await service._match_annotations(
query=query,
knowledge_base_ids=knowledge_base_ids,
embedding_model_id=first_kb.embedding_model_id,
score_threshold=annotation_threshold,
dimensions=first_kb.embedding_dimensions,
max_results=1,
)
if annotation_results:
best = annotation_results[0]
metadata = getattr(best, 'metadata', {}) or {}
logger.info(
f'标注直接回复命中: score={best.score}, '
f'question={metadata.get("question", "")}, answer={best.content[:50]}'
)
# 记录检索日志
try:
from ai_platform.knowledge.models import KnowledgeRetrievalLog
log = KnowledgeRetrievalLog(
query=query,
knowledge_base_ids=knowledge_base_ids,
retrieval_mode='annotation',
top_k=1,
score_threshold=annotation_threshold,
result_count=1,
results=[{'segment_id': best.segment_id, 'score': best.score, 'kb_id': best.knowledge_base_id}],
rerank_applied='false',
elapsed_time=0,
source='agent_annotation',
)
self._db.add(log)
await self._db.flush()
except Exception as e:
logger.warning(f'记录标注回复日志失败: {e}')
return {
'answer': best.content,
'score': best.score,
'question': metadata.get('question', ''),
}
except Exception as e:
logger.warning(f'标注直接回复检查失败: {e}')
return None
async def _retrieve_knowledge_context(
self,
agent: Agent,
query: str,
) -> Optional[Dict[str, Any]]:
"""
检索知识库上下文(RAG)
Args:
agent: 智能体(包含 knowledge_base_ids 和 knowledge_config
query: 用户查询文本
Returns:
知识库上下文字典,无结果时返回 None
"""
knowledge_base_ids = getattr(agent, 'knowledge_base_ids', None) or []
if not knowledge_base_ids or not self._db:
logger.info(f'RAG 跳过: knowledge_base_ids={knowledge_base_ids}, db={bool(self._db)}')
return None
knowledge_config = getattr(agent, 'knowledge_config', None) or {}
logger.info(f'RAG 开始: kb_ids={knowledge_base_ids}, config={knowledge_config}, query={query[:100]}')
top_k = knowledge_config.get('top_k', 5)
score_threshold = knowledge_config.get('score_threshold', 0.5)
retrieval_mode = knowledge_config.get('retrieval_mode', None)
rerank_enabled = knowledge_config.get('rerank_enabled', None)
rerank_model_id = knowledge_config.get('rerank_model_id', None)
try:
from ai_platform.knowledge.services.retrieval_service import RetrievalService
start_time = time.time()
service = RetrievalService(self._db)
results = await service.retrieve(
query=query,
knowledge_base_ids=knowledge_base_ids,
top_k=top_k,
score_threshold=score_threshold,
retrieval_mode=retrieval_mode,
rerank_enabled=rerank_enabled,
rerank_model_id=rerank_model_id,
)
elapsed = int((time.time() - start_time) * 1000)
logger.info(f'RAG 检索结果: {len(results)}')
for r in results:
logger.info(f' - segment_id={r.segment_id}, score={r.score}, doc={getattr(r, "document_name", "")}, type={getattr(r, "metadata", {}).get("type", "segment")}')
# 记录检索日志(与召回测试共用同一张日志表)
try:
from ai_platform.knowledge.models import KnowledgeRetrievalLog
log = KnowledgeRetrievalLog(
query=query,
knowledge_base_ids=knowledge_base_ids,
retrieval_mode=retrieval_mode or 'hybrid',
top_k=top_k,
score_threshold=score_threshold,
result_count=len(results),
results=[
{'segment_id': r.segment_id, 'score': r.score, 'kb_id': r.knowledge_base_id}
for r in results
],
rerank_applied='true' if rerank_enabled else 'false',
elapsed_time=elapsed,
source='agent',
)
self._db.add(log)
await self._db.flush()
except Exception as e:
logger.warning(f'记录 Agent 检索日志失败: {e}')
if not results:
return None
# 构建上下文文本
context_parts = []
for i, r in enumerate(results, 1):
score = getattr(r, 'score', 0)
content = getattr(r, 'content', '')
metadata = getattr(r, 'metadata', {}) or {}
if metadata.get('type') == 'annotation':
# 标注类型:显示为 Q&A 对,让 LLM 明确知道这是预设的标准答案
question = metadata.get('question', '')
context_parts.append(
f"[{i}] [标准问答] (相似度: {score:.2f})\n"
f"问题: {question}\n"
f"标准答案: {content}"
)
else:
source = getattr(r, 'document_name', '') or ''
context_parts.append(
f"[{i}] (来源: {source}, 相似度: {score:.2f})\n{content}"
)
context_text = '\n\n'.join(context_parts)
summary = f"检索到 {len(results)} 条相关结果"
return {
'context': context_text,
'result_count': len(results),
'summary': summary,
'results': [
{
'segment_id': getattr(r, 'segment_id', ''),
'document_name': getattr(r, 'document_name', ''),
'score': getattr(r, 'score', 0),
}
for r in results
],
}
except Exception as e:
logger.warning(f'知识库检索失败: {e}')
return None
@staticmethod
def _inject_knowledge_context(
system_prompt: str,
knowledge_context: Dict[str, Any],
) -> str:
"""
将知识库检索结果注入系统提示词
参考 Dify 的 RAG 注入方式:在 system prompt 末尾追加参考资料段落,
并指示 LLM 基于资料回答,资料中无相关信息时如实告知。
"""
context_text = knowledge_context.get('context', '')
if not context_text:
return system_prompt
rag_instruction = (
"\n\n"
"# 参考资料\n"
"以下是从知识库中检索到的与用户问题相关的参考资料。\n"
"- 标记为[标准问答]的条目是预设的权威问答对,当用户问题与其匹配时,请直接使用标准答案回答,不要自行发挥。\n"
"- 其他条目为文档参考内容,请基于这些资料回答用户的问题。\n"
"- 如果参考资料中没有相关信息,请基于你自身的知识回答,并说明该回答未基于知识库。\n\n"
f"{context_text}"
)
return system_prompt + rag_instruction