feat: 添加数字人服务及相关部署文件
This commit is contained in:
374
digital-human.py
374
digital-human.py
@@ -1,258 +1,176 @@
|
|||||||
"""
|
|
||||||
数字人对接中转服务
|
|
||||||
-----------------
|
|
||||||
作为客户端与 Dify LLM 平台之间的中间转发层,处理请求路由、
|
|
||||||
认证转发及 Redis 缓存,提升响应效率并保护后端 API Key。
|
|
||||||
|
|
||||||
环境变量:
|
|
||||||
DIFY_URL - Dify 服务地址,如 https://atwdify.antiwearvalve.com/v1
|
|
||||||
DIFY_API_KEY - Dify API 密钥,如 app-PDkSHiHZsZIluoZF7N2OlNZM
|
|
||||||
REDIS_HOST - Redis 主机地址,默认 localhost
|
|
||||||
REDIS_PORT - Redis 端口,默认 6379
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import redis
|
import redis
|
||||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
import uuid
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
import requests
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi import FastAPI, Query, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
app = FastAPI()
|
||||||
# 日志配置
|
# 连接到Redis服务器(默认端口是6379)
|
||||||
# ──────────────────────────────────────────────────────────────
|
redis_instance = redis.Redis(host='localhost', port=6379, db=0)
|
||||||
logging.basicConfig(
|
mock_time_created = 20230512084843
|
||||||
level=logging.INFO,
|
mock_id1 = "131424542124"
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
mock_id2 = "1231414141541252525"
|
||||||
)
|
mock_user = "abc-123"
|
||||||
logger = logging.getLogger("digital-human")
|
dify_url = "http://<your dify ip>/v1/chat-messages"
|
||||||
|
dify_api_key = 'Bearer <your dify api key>'
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 应用配置(从环境变量读取)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
DIFY_URL: str = os.getenv("DIFY_URL", "")
|
|
||||||
DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "")
|
|
||||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
|
||||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
|
||||||
|
|
||||||
# 验证必填配置
|
|
||||||
if not DIFY_URL or not DIFY_API_KEY:
|
|
||||||
logger.warning(
|
|
||||||
"DIFY_URL 或 DIFY_API_KEY 未配置,"
|
|
||||||
"部分功能可能不可用。请通过环境变量设置。"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# Redis 客户端(懒加载)
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
_redis_client: Optional[redis.Redis] = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> redis.Redis:
|
class Message(BaseModel):
|
||||||
"""获取 Redis 客户端实例(延迟初始化)。"""
|
content: str
|
||||||
global _redis_client
|
|
||||||
if _redis_client is None:
|
|
||||||
_redis_client = redis.Redis(
|
|
||||||
host=REDIS_HOST,
|
|
||||||
port=REDIS_PORT,
|
|
||||||
decode_responses=True,
|
|
||||||
socket_connect_timeout=5,
|
|
||||||
)
|
|
||||||
return _redis_client
|
|
||||||
|
|
||||||
|
|
||||||
def get_cache_key(payload: Dict[str, Any]) -> str:
|
class Resquest_Body(BaseModel):
|
||||||
"""根据请求体生成 Redis 缓存键。"""
|
messages: List[Message] = []
|
||||||
raw = json.dumps(payload, sort_keys=True)
|
app_id: str
|
||||||
return f"dh:cache:{hashlib.md5(raw.encode()).hexdigest()}"
|
user: str
|
||||||
|
session_id: str
|
||||||
|
is_stream: bool
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# 对接dify,非流式传输
|
||||||
# FastAPI 应用生命周期
|
def request_dify_api(question, request, conversation_id):
|
||||||
# ──────────────────────────────────────────────────────────────
|
# 目标URL
|
||||||
@asynccontextmanager
|
url = dify_url
|
||||||
async def lifespan(app: FastAPI):
|
headers_json = {
|
||||||
"""应用启动 / 关闭钩子。"""
|
'Content-Type': 'application/json',
|
||||||
logger.info("数字人对接服务启动中...")
|
'Authorization': dify_api_key
|
||||||
logger.info(f" Dify URL : {DIFY_URL}")
|
}
|
||||||
logger.info(f" Redis : {REDIS_HOST}:{REDIS_PORT}")
|
# 请求的数据
|
||||||
|
data = {
|
||||||
# 尝试连接 Redis(仅记录,不阻塞启动)
|
"inputs": {},
|
||||||
try:
|
"query": question,
|
||||||
get_redis().ping()
|
"response_mode": "blocking",
|
||||||
logger.info("Redis 连接成功")
|
"user": mock_user
|
||||||
except RedisConnectionError as e:
|
|
||||||
logger.warning(f"Redis 连接失败(服务将继续运行): {e}")
|
|
||||||
|
|
||||||
yield
|
|
||||||
|
|
||||||
logger.info("数字人对接服务已关闭")
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title="数字人对接中转服务",
|
|
||||||
version="1.0.0",
|
|
||||||
description="对接 Dify LLM 平台的数字人应用中转服务",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# 健康检查
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
@app.get("/")
|
|
||||||
async def root():
|
|
||||||
"""服务健康检查端点。"""
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"service": "digital-human-relay",
|
|
||||||
"version": "1.0.0",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 发送POST请求
|
||||||
|
response = requests.post(url, data=json.dumps(data), headers=headers_json)
|
||||||
|
data = json.loads(response)
|
||||||
|
return data["answer"]
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
"""详细健康检查(包含 Redis 状态)。"""
|
|
||||||
redis_ok = False
|
|
||||||
try:
|
|
||||||
get_redis().ping()
|
|
||||||
redis_ok = True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
# 对接dify,流式传输
|
||||||
"status": "healthy" if redis_ok else "degraded",
|
async def generate_streaming_messages(question, request, session_id, conversation_id):
|
||||||
"redis": "connected" if redis_ok else "disconnected",
|
url = dify_url
|
||||||
"dify_configured": bool(DIFY_URL and DIFY_API_KEY),
|
headers_json = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': dify_api_key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 请求的数据
|
||||||
# ──────────────────────────────────────────────────────────────
|
request_data = {
|
||||||
# Dify 响应格式转 OpenAI 兼容格式
|
"inputs": {},
|
||||||
# ──────────────────────────────────────────────────────────────
|
"query": question,
|
||||||
def dify_to_openai(dify_resp: Dict[str, Any]) -> Dict[str, Any]:
|
"conversation_id": conversation_id,
|
||||||
"""
|
"response_mode": "streaming",
|
||||||
将 Dify /chat-messages 的响应转换为 OpenAI /chat/completions 兼容格式。
|
"user": mock_user
|
||||||
Dify 响应示例: { answer: "...", conversation_id: "...", task_id: "..." }
|
|
||||||
OpenAI 目标格式: { id, model, choices: [{ message: { role: "assistant", content: "..." } }] }
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"id": f"chatcmpl-{dify_resp.get('task_id', 'unknown')}",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": 0,
|
|
||||||
"model": "dify",
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": dify_resp.get("answer", ""),
|
|
||||||
},
|
|
||||||
"finish_reason": "stop",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 0,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"total_tokens": 0,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
r = requests.post(url, json=request_data, headers=headers_json, verify=False, stream=True)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise Exception(r.text)
|
||||||
|
|
||||||
|
data = ""
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# 在这里,我们使用了 iter_lines 方法来逐行读取响应体
|
||||||
# 核心转发接口
|
for line in r.iter_lines():
|
||||||
# ──────────────────────────────────────────────────────────────
|
if await request.is_disconnected():
|
||||||
@app.post("/v1/chat/completions")
|
# 客户端已主动断开,中止流式输出,避免不必要的问题,打印报错信息方便定位
|
||||||
async def chat_completions(request: Request):
|
print("# 客户端已主动断开,中止流式输出")
|
||||||
"""
|
|
||||||
接收 OpenAI 兼容格式请求,转换为 Dify /chat-messages 格式后转发。
|
|
||||||
OpenAI 格式: { model, messages: [{role, content}], stream }
|
|
||||||
Dify 格式: { query, user, response_mode, inputs }
|
|
||||||
"""
|
|
||||||
body = await request.json()
|
|
||||||
|
|
||||||
# ── 格式转换:OpenAI -> Dify ──────────────────────────────
|
|
||||||
# 提取用户最新消息
|
|
||||||
user_content = ""
|
|
||||||
for msg in reversed(body.get("messages", [])):
|
|
||||||
if msg.get("role") == "user":
|
|
||||||
user_content = msg.get("content", "")
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if not user_content:
|
# 将每一行解码为字符串
|
||||||
raise HTTPException(status_code=400, detail="未找到用户消息")
|
line = line.decode('utf-8').strip()
|
||||||
|
|
||||||
# 生成或复用 user 标识(用于 Dify 会话上下文)
|
# 接下来我们要处理三种不同的情况:
|
||||||
dify_user = body.get("user", "anonymous")
|
# 1. 如果当前行是空行,则表明前一个数据块已接收完毕(即前文提到的,通过两个换行符结束数据块传输),我们可以对该数据块进行反序列化,并打印出对应的 content 内容;
|
||||||
response_mode = "blocking" if not body.get("stream", False) else "streaming"
|
# 2. 如果当前行为非空行,且以 data: 开头,则表明这是一个数据块传输的开始,我们去除 data: 前缀后,首先判断是否是结束符 [DONE],如果不是,将数据内容保存到 data 变量;
|
||||||
|
# 3. 如果当前行为非空行,但不以 data: 开头,则表明当前行仍然归属上一个正在传输的数据块,我们将当前行的内容追加到 data 变量尾部;
|
||||||
|
|
||||||
# 构建 Dify 请求体
|
if len(line) == 0:
|
||||||
dify_payload = {
|
if data:
|
||||||
"query": user_content,
|
|
||||||
"user": dify_user,
|
|
||||||
"response_mode": response_mode,
|
|
||||||
"inputs": body.get("inputs", {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 若应用要求 inputs 字段但为空,补上空对象
|
|
||||||
if "inputs" not in dify_payload or dify_payload["inputs"] is None:
|
|
||||||
dify_payload["inputs"] = {}
|
|
||||||
|
|
||||||
# ── 检查 Redis 缓存(基于 OpenAI 原始请求体)──────────────
|
|
||||||
cache_key = get_cache_key(body)
|
|
||||||
try:
|
try:
|
||||||
cached = get_redis().get(cache_key)
|
chunk = json.loads(data)
|
||||||
if cached:
|
except:
|
||||||
logger.info(f"缓存命中: {cache_key}")
|
continue
|
||||||
return dify_to_openai(json.loads(cached))
|
|
||||||
except RedisConnectionError:
|
|
||||||
logger.warning("Redis 不可用,跳过缓存读取")
|
|
||||||
|
|
||||||
# ── 转发到 Dify ─────────────────────────────────────────
|
if chunk["event"] != "message":
|
||||||
|
continue
|
||||||
|
# 如果redis里未缓存该session_id,则使用reids关联session_id和conversation_id,保证后续实现多轮对话
|
||||||
|
if not redis_instance.exists(session_id):
|
||||||
|
conversiation_id = str(chunk["conversation_id"])
|
||||||
|
redis_instance.set(session_id, conversiation_id)
|
||||||
|
|
||||||
|
# 设置redis中key(session_id)的超时时间,一天换算86400s
|
||||||
|
redis_instance.expire(session_id, 86400)
|
||||||
|
content = chunk["answer"]
|
||||||
|
new_data = {
|
||||||
|
"id": mock_id2,
|
||||||
|
"created": mock_time_created,
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": content
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(new_data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
data = "" # 重置 data
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data = line.lstrip("data: ")
|
||||||
|
|
||||||
|
# 当数据块内容为 [DONE] 时,则表明所有数据块已发送完毕,可断开网络连接
|
||||||
|
if data == "[DONE]":
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 仍然在追加内容时,为其添加一个换行符
|
||||||
|
data = data + "\n" + line
|
||||||
|
|
||||||
|
# 发送结束标记
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/digital-human")
|
||||||
|
async def digital_human(body: Resquest_Body, request: Request):
|
||||||
|
question = body.messages[0].content
|
||||||
|
session_id = body.session_id
|
||||||
|
conversation_id = ""
|
||||||
|
|
||||||
|
# 如果redis_instance里有缓存session_id,则说明为多轮对话,获取对应的dify中的多轮对话关键参数conversation_id
|
||||||
|
if redis_instance.exists(session_id):
|
||||||
|
# decode()用于将bytes转换为str,因为redis_instance存储的是bytes类型的数据。
|
||||||
|
conversation_id = redis_instance.get(session_id).decode("utf-8")
|
||||||
|
|
||||||
|
# 选择流式传输
|
||||||
|
if body.is_stream:
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_streaming_messages(question, request, session_id, conversation_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {DIFY_API_KEY}",
|
"Cache-Control": "no-cache",
|
||||||
"Content-Type": "application/json",
|
"Connection": "keep-alive",
|
||||||
}
|
}
|
||||||
dify_endpoint = f"{DIFY_URL.rstrip('/')}/chat-messages"
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
||||||
resp = await client.post(dify_endpoint, json=dify_payload, headers=headers)
|
|
||||||
resp.raise_for_status()
|
|
||||||
result = resp.json()
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(f"Dify 返回错误 {e.response.status_code}: {e.response.text}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=e.response.status_code,
|
|
||||||
detail=f"Dify 请求失败: {e.response.text}",
|
|
||||||
)
|
)
|
||||||
except httpx.RequestError as e:
|
|
||||||
logger.error(f"无法连接 Dify 服务: {e}")
|
|
||||||
raise HTTPException(status_code=502, detail=f"无法连接 Dify 服务: {e}")
|
|
||||||
|
|
||||||
# ── 写入缓存(存 Dify 原始格式)───────────────────────────
|
# 选择非流式传输
|
||||||
try:
|
answer = request_dify_api(question, request, conversation_id)
|
||||||
get_redis().setex(cache_key, 300, json.dumps(result))
|
choices = [{
|
||||||
except RedisConnectionError:
|
"index": 0,
|
||||||
pass
|
"message": {
|
||||||
|
"content": answer
|
||||||
# ── 响应转 OpenAI 兼容格式 ─────────────────────────────────
|
}
|
||||||
return dify_to_openai(result)
|
}]
|
||||||
|
return {
|
||||||
|
"id": mock_id1,
|
||||||
|
"created": mock_time_created,
|
||||||
|
"choices": choices
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# 启动应用
|
||||||
# 错误处理器
|
if __name__ == "__main__":
|
||||||
# ──────────────────────────────────────────────────────────────
|
import uvicorn
|
||||||
@app.exception_handler(Exception)
|
|
||||||
async def global_exception_handler(request: Request, exc: Exception):
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
logger.exception(f"未处理的异常: {exc}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"error": "Internal Server Error", "detail": str(exc)},
|
|
||||||
)
|
|
||||||
|
|||||||
258
digital-human2.py
Normal file
258
digital-human2.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
数字人对接中转服务
|
||||||
|
-----------------
|
||||||
|
作为客户端与 Dify LLM 平台之间的中间转发层,处理请求路由、
|
||||||
|
认证转发及 Redis 缓存,提升响应效率并保护后端 API Key。
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
DIFY_URL - Dify 服务地址,如 https://atwdify.antiwearvalve.com/v1
|
||||||
|
DIFY_API_KEY - Dify API 密钥,如 app-PDkSHiHZsZIluoZF7N2OlNZM
|
||||||
|
REDIS_HOST - Redis 主机地址,默认 localhost
|
||||||
|
REDIS_PORT - Redis 端口,默认 6379
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis
|
||||||
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 日志配置
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("digital-human")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 应用配置(从环境变量读取)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
DIFY_URL: str = os.getenv("DIFY_URL", "")
|
||||||
|
DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "")
|
||||||
|
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
||||||
|
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||||
|
|
||||||
|
# 验证必填配置
|
||||||
|
if not DIFY_URL or not DIFY_API_KEY:
|
||||||
|
logger.warning(
|
||||||
|
"DIFY_URL 或 DIFY_API_KEY 未配置,"
|
||||||
|
"部分功能可能不可用。请通过环境变量设置。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Redis 客户端(懒加载)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
_redis_client: Optional[redis.Redis] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis() -> redis.Redis:
|
||||||
|
"""获取 Redis 客户端实例(延迟初始化)。"""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
_redis_client = redis.Redis(
|
||||||
|
host=REDIS_HOST,
|
||||||
|
port=REDIS_PORT,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=5,
|
||||||
|
)
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_cache_key(payload: Dict[str, Any]) -> str:
|
||||||
|
"""根据请求体生成 Redis 缓存键。"""
|
||||||
|
raw = json.dumps(payload, sort_keys=True)
|
||||||
|
return f"dh:cache:{hashlib.md5(raw.encode()).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# FastAPI 应用生命周期
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用启动 / 关闭钩子。"""
|
||||||
|
logger.info("数字人对接服务启动中...")
|
||||||
|
logger.info(f" Dify URL : {DIFY_URL}")
|
||||||
|
logger.info(f" Redis : {REDIS_HOST}:{REDIS_PORT}")
|
||||||
|
|
||||||
|
# 尝试连接 Redis(仅记录,不阻塞启动)
|
||||||
|
try:
|
||||||
|
get_redis().ping()
|
||||||
|
logger.info("Redis 连接成功")
|
||||||
|
except RedisConnectionError as e:
|
||||||
|
logger.warning(f"Redis 连接失败(服务将继续运行): {e}")
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
logger.info("数字人对接服务已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="数字人对接中转服务",
|
||||||
|
version="1.0.0",
|
||||||
|
description="对接 Dify LLM 平台的数字人应用中转服务",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 健康检查
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""服务健康检查端点。"""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "digital-human-relay",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""详细健康检查(包含 Redis 状态)。"""
|
||||||
|
redis_ok = False
|
||||||
|
try:
|
||||||
|
get_redis().ping()
|
||||||
|
redis_ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy" if redis_ok else "degraded",
|
||||||
|
"redis": "connected" if redis_ok else "disconnected",
|
||||||
|
"dify_configured": bool(DIFY_URL and DIFY_API_KEY),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Dify 响应格式转 OpenAI 兼容格式
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
def dify_to_openai(dify_resp: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将 Dify /chat-messages 的响应转换为 OpenAI /chat/completions 兼容格式。
|
||||||
|
Dify 响应示例: { answer: "...", conversation_id: "...", task_id: "..." }
|
||||||
|
OpenAI 目标格式: { id, model, choices: [{ message: { role: "assistant", content: "..." } }] }
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": f"chatcmpl-{dify_resp.get('task_id', 'unknown')}",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": 0,
|
||||||
|
"model": "dify",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": dify_resp.get("answer", ""),
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 核心转发接口
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
@app.post("/v1/chat/completions")
|
||||||
|
async def chat_completions(request: Request):
|
||||||
|
"""
|
||||||
|
接收 OpenAI 兼容格式请求,转换为 Dify /chat-messages 格式后转发。
|
||||||
|
OpenAI 格式: { model, messages: [{role, content}], stream }
|
||||||
|
Dify 格式: { query, user, response_mode, inputs }
|
||||||
|
"""
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
# ── 格式转换:OpenAI -> Dify ──────────────────────────────
|
||||||
|
# 提取用户最新消息
|
||||||
|
user_content = ""
|
||||||
|
for msg in reversed(body.get("messages", [])):
|
||||||
|
if msg.get("role") == "user":
|
||||||
|
user_content = msg.get("content", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not user_content:
|
||||||
|
raise HTTPException(status_code=400, detail="未找到用户消息")
|
||||||
|
|
||||||
|
# 生成或复用 user 标识(用于 Dify 会话上下文)
|
||||||
|
dify_user = body.get("user", "anonymous")
|
||||||
|
response_mode = "blocking" if not body.get("stream", False) else "streaming"
|
||||||
|
|
||||||
|
# 构建 Dify 请求体
|
||||||
|
dify_payload = {
|
||||||
|
"query": user_content,
|
||||||
|
"user": dify_user,
|
||||||
|
"response_mode": response_mode,
|
||||||
|
"inputs": body.get("inputs", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 若应用要求 inputs 字段但为空,补上空对象
|
||||||
|
if "inputs" not in dify_payload or dify_payload["inputs"] is None:
|
||||||
|
dify_payload["inputs"] = {}
|
||||||
|
|
||||||
|
# ── 检查 Redis 缓存(基于 OpenAI 原始请求体)──────────────
|
||||||
|
cache_key = get_cache_key(body)
|
||||||
|
try:
|
||||||
|
cached = get_redis().get(cache_key)
|
||||||
|
if cached:
|
||||||
|
logger.info(f"缓存命中: {cache_key}")
|
||||||
|
return dify_to_openai(json.loads(cached))
|
||||||
|
except RedisConnectionError:
|
||||||
|
logger.warning("Redis 不可用,跳过缓存读取")
|
||||||
|
|
||||||
|
# ── 转发到 Dify ─────────────────────────────────────────
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {DIFY_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
dify_endpoint = f"{DIFY_URL.rstrip('/')}/chat-messages"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(dify_endpoint, json=dify_payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Dify 返回错误 {e.response.status_code}: {e.response.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"Dify 请求失败: {e.response.text}",
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"无法连接 Dify 服务: {e}")
|
||||||
|
raise HTTPException(status_code=502, detail=f"无法连接 Dify 服务: {e}")
|
||||||
|
|
||||||
|
# ── 写入缓存(存 Dify 原始格式)───────────────────────────
|
||||||
|
try:
|
||||||
|
get_redis().setex(cache_key, 300, json.dumps(result))
|
||||||
|
except RedisConnectionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── 响应转 OpenAI 兼容格式 ─────────────────────────────────
|
||||||
|
return dify_to_openai(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# 错误处理器
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
logger.exception(f"未处理的异常: {exc}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Internal Server Error", "detail": str(exc)},
|
||||||
|
)
|
||||||
176
downloads/digital-human.py
Normal file
176
downloads/digital-human.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import json
|
||||||
|
import redis
|
||||||
|
import uuid
|
||||||
|
import requests
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi import FastAPI, Query, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
# 连接到Redis服务器(默认端口是6379)
|
||||||
|
redis_instance = redis.Redis(host='localhost', port=6379, db=0)
|
||||||
|
mock_time_created = 20230512084843
|
||||||
|
mock_id1 = "131424542124"
|
||||||
|
mock_id2 = "1231414141541252525"
|
||||||
|
mock_user = "abc-123"
|
||||||
|
dify_url = "http://<your dify ip>/v1/chat-messages"
|
||||||
|
dify_api_key = 'Bearer <your dify api key>'
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class Resquest_Body(BaseModel):
|
||||||
|
messages: List[Message] = []
|
||||||
|
app_id: str
|
||||||
|
user: str
|
||||||
|
session_id: str
|
||||||
|
is_stream: bool
|
||||||
|
|
||||||
|
|
||||||
|
# 对接dify,非流式传输
|
||||||
|
def request_dify_api(question, request, conversation_id):
|
||||||
|
# 目标URL
|
||||||
|
url = dify_url
|
||||||
|
headers_json = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': dify_api_key
|
||||||
|
}
|
||||||
|
# 请求的数据
|
||||||
|
data = {
|
||||||
|
"inputs": {},
|
||||||
|
"query": question,
|
||||||
|
"response_mode": "blocking",
|
||||||
|
"user": mock_user
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送POST请求
|
||||||
|
response = requests.post(url, data=json.dumps(data), headers=headers_json)
|
||||||
|
data = json.loads(response)
|
||||||
|
return data["answer"]
|
||||||
|
|
||||||
|
|
||||||
|
# 对接dify,流式传输
|
||||||
|
async def generate_streaming_messages(question, request, session_id, conversation_id):
|
||||||
|
url = dify_url
|
||||||
|
headers_json = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': dify_api_key
|
||||||
|
}
|
||||||
|
|
||||||
|
# 请求的数据
|
||||||
|
request_data = {
|
||||||
|
"inputs": {},
|
||||||
|
"query": question,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"response_mode": "streaming",
|
||||||
|
"user": mock_user
|
||||||
|
}
|
||||||
|
r = requests.post(url, json=request_data, headers=headers_json, verify=False, stream=True)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise Exception(r.text)
|
||||||
|
|
||||||
|
data = ""
|
||||||
|
|
||||||
|
# 在这里,我们使用了 iter_lines 方法来逐行读取响应体
|
||||||
|
for line in r.iter_lines():
|
||||||
|
if await request.is_disconnected():
|
||||||
|
# 客户端已主动断开,中止流式输出,避免不必要的问题,打印报错信息方便定位
|
||||||
|
print("# 客户端已主动断开,中止流式输出")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 将每一行解码为字符串
|
||||||
|
line = line.decode('utf-8').strip()
|
||||||
|
|
||||||
|
# 接下来我们要处理三种不同的情况:
|
||||||
|
# 1. 如果当前行是空行,则表明前一个数据块已接收完毕(即前文提到的,通过两个换行符结束数据块传输),我们可以对该数据块进行反序列化,并打印出对应的 content 内容;
|
||||||
|
# 2. 如果当前行为非空行,且以 data: 开头,则表明这是一个数据块传输的开始,我们去除 data: 前缀后,首先判断是否是结束符 [DONE],如果不是,将数据内容保存到 data 变量;
|
||||||
|
# 3. 如果当前行为非空行,但不以 data: 开头,则表明当前行仍然归属上一个正在传输的数据块,我们将当前行的内容追加到 data 变量尾部;
|
||||||
|
|
||||||
|
if len(line) == 0:
|
||||||
|
if data:
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if chunk["event"] != "message":
|
||||||
|
continue
|
||||||
|
# 如果redis里未缓存该session_id,则使用reids关联session_id和conversation_id,保证后续实现多轮对话
|
||||||
|
if not redis_instance.exists(session_id):
|
||||||
|
conversiation_id = str(chunk["conversation_id"])
|
||||||
|
redis_instance.set(session_id, conversiation_id)
|
||||||
|
|
||||||
|
# 设置redis中key(session_id)的超时时间,一天换算86400s
|
||||||
|
redis_instance.expire(session_id, 86400)
|
||||||
|
content = chunk["answer"]
|
||||||
|
new_data = {
|
||||||
|
"id": mock_id2,
|
||||||
|
"created": mock_time_created,
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": content
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(new_data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
data = "" # 重置 data
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data = line.lstrip("data: ")
|
||||||
|
|
||||||
|
# 当数据块内容为 [DONE] 时,则表明所有数据块已发送完毕,可断开网络连接
|
||||||
|
if data == "[DONE]":
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# 仍然在追加内容时,为其添加一个换行符
|
||||||
|
data = data + "\n" + line
|
||||||
|
|
||||||
|
# 发送结束标记
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/digital-human")
|
||||||
|
async def digital_human(body: Resquest_Body, request: Request):
|
||||||
|
question = body.messages[0].content
|
||||||
|
session_id = body.session_id
|
||||||
|
conversation_id = ""
|
||||||
|
|
||||||
|
# 如果redis_instance里有缓存session_id,则说明为多轮对话,获取对应的dify中的多轮对话关键参数conversation_id
|
||||||
|
if redis_instance.exists(session_id):
|
||||||
|
# decode()用于将bytes转换为str,因为redis_instance存储的是bytes类型的数据。
|
||||||
|
conversation_id = redis_instance.get(session_id).decode("utf-8")
|
||||||
|
|
||||||
|
# 选择流式传输
|
||||||
|
if body.is_stream:
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_streaming_messages(question, request, session_id, conversation_id),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 选择非流式传输
|
||||||
|
answer = request_dify_api(question, request, conversation_id)
|
||||||
|
choices = [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {
|
||||||
|
"content": answer
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
return {
|
||||||
|
"id": mock_id1,
|
||||||
|
"created": mock_time_created,
|
||||||
|
"choices": choices
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 启动应用
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
11
downloads/digital-human.service
Normal file
11
downloads/digital-human.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Digital Human Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=nohup python3 /digital-human.py &
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
33
downloads/digital-human.sh
Normal file
33
downloads/digital-human.sh
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 回到根路径
|
||||||
|
cd /
|
||||||
|
rm -rf /digital-human.rar
|
||||||
|
rm -rf /digital-human.py
|
||||||
|
rm -rf /etc/systemd/system/digital-human.service
|
||||||
|
|
||||||
|
# apt安装rar和redis-server
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install rar redis-server
|
||||||
|
|
||||||
|
#启动redis-server,并设置开机自启动 redis-server
|
||||||
|
sudo systemctl enable redis-server
|
||||||
|
sudo systemctl start redis-server
|
||||||
|
|
||||||
|
# pip安装所需的包
|
||||||
|
pip install redis fastapi uvicorn -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
|
# 从obs桶获取转发服务代码
|
||||||
|
wget https://documentation-samples.obs.cn-north-4.myhuaweicloud.com/solution-as-code-publicbucket/solution-as-code-moudle/building-a-dify-llm-application-development-platform/human/digital-human.rar
|
||||||
|
rar x digital-human.rar
|
||||||
|
|
||||||
|
# 替换dify对应的url和api key
|
||||||
|
sed -i "s/<your dify ip>/$1/g" digital-human.py
|
||||||
|
sed -i "s/<your dify api key>/$2/g" digital-human.py
|
||||||
|
|
||||||
|
#启动对接数字人的python服务,并设置开机自启动
|
||||||
|
mv digital-human.service /etc/systemd/system/digital-human.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable digital-human.service
|
||||||
|
sudo systemctl start digital-human.service
|
||||||
|
|
||||||
Reference in New Issue
Block a user