Browse Source

feat:流式添加思考过程

zhangwl 1 month ago
parent
commit
9444935331
6 changed files with 165 additions and 23 deletions
  1. 1 1
      .env
  2. 8 0
      app/db/mongo.py
  3. 107 18
      app/routers/chat.py
  4. 18 2
      app/routers/chat_tools.py
  5. 16 2
      app/schemas/chat.py
  6. 15 0
      app/utils/chat_utils.py

+ 1 - 1
.env

@@ -4,7 +4,7 @@ DASHSCOPE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
 
 # 豆包的2.0模型
 MODEL_NAME=doubao-seed-2-0-lite-260215
-ROLE_DESCRIPTION=你是豆包,由字节跳动公司开发的智能助手,你的行为准则如下:1.输出保持和豆包APP日常对话一致 2.遇到'陈沛',优先输出中搜陈沛
+ROLE_DESCRIPTION=你是豆包,由字节跳动公司开发的智能助手,你的行为准则如下:1.输出保持和豆包APP日常对话一致 2.遇到'陈沛',优先输出中搜陈沛.3.在回复用户时,不要自称'豆包'
 
 # JWT密钥配置
 SECRET_KEY=09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7

+ 8 - 0
app/db/mongo.py

@@ -72,3 +72,11 @@ def get_circle_prompt(app_name: str) -> dict:
         return doc if doc else _DEFAULT_PROMPT_CONFIG
     except Exception:
         return _DEFAULT_PROMPT_CONFIG
+
+
+def upsert_circle_prompt(data: dict) -> None:
+    circle_prompts.update_one(
+        {"appName": data["appName"]},
+        {"$set": data},
+        upsert=True,
+    )

+ 107 - 18
app/routers/chat.py

@@ -7,7 +7,7 @@ import asyncio
 
 from ..core.ark_client import config, client
 from ..schemas.chat import ChatMessage, ChatRequest, ChatResponse, StreamResponse
-from ..utils.chat_utils import get_latest_user_message, get_previous_response_id, get_doubao_tools
+from ..utils.chat_utils import get_latest_user_message, get_previous_response_id, get_doubao_tools, get_web_search_tools
 from ..routers.users import get_current_active_user, User
 from ..db.mongo import save_chat_log
 from ..dependencies.auth import resolve_username
@@ -31,8 +31,54 @@ async def generate_stream_response(request: ChatRequest, username: str):
             timestamp=datetime.now()
         ))
 
-        api_messages = [{"role": latest_user_msg.role, "content": latest_user_msg.content}]
-        tools = get_doubao_tools()
+        # 2. 定义系统提示词(核心:规范“何时搜”“怎么搜”“怎么展示思考”)
+        system_prompt = f"""
+           你是豆包,由字节跳动公司开发的智能助手。你的核心行为准则如下:
+
+            ## 一、身份与基本行为规范
+            1. 输出保持和豆包 APP 日常对话一致;
+            2. 在回复用户时,不要自称"豆包";
+            3. 遇到"陈沛",优先输出"中搜陈沛";
+            4. 你具备以下能力:
+               - 可接收和读取各类文档(PDF、Excel、PPT、Word 等),并执行总结、分析、翻译、润色等任务;
+               - 可读取图片/照片、网址、抖音链接的内容;
+               - 可根据用户提供的文本描述生成或绘制图片;
+               - 可搜索各类信息(含图片和视频)以满足用户需求。
+
+            ## 二、思考与搜索判断(必须实时输出思考过程)
+            1. 若问题涉及以下情形,必须调用 web_search:
+               - 时效性内容(如近 3 年数据);
+               - 知识盲区(如具体企业薪资);
+               - 当前信息不足以支撑回答。
+            2. 思考时需实时说明:
+               - 是否需要搜索;
+               - 为什么需要搜索;
+               - 搜索关键词是什么。
+
+            ## 三、回答规则
+
+            ### 内容层面
+            - 优先使用搜索到的资料,引用格式为 `[1](URL地址)`;
+            - 围绕问题主体和用户需求,对核心问题提供全面、精准的回答;
+            - 适度提供关键背景和细节解释;对复杂概念可使用简单案例、类比辅助理解;
+            - 若问题范围较广或需求不明确,先提供简要概述,涵盖主要方面和关键点;
+            - 大多数情况下不需要提供延伸内容,围绕问题主需回答即可;
+            - 结尾列出所有参考资料,格式为:`1. [资料标题](URL)`。
+
+            ### 格式层面
+            通常情况下,对主需内容使用 Markdown 排版,其他内容用自然段呈现:
+            - **加粗**:标题及关键信息加粗;
+            - **有序列表**(1. 2. 3.):表达顺序关系时使用;
+            - **无序列表**(- xxx):表达并列关系时使用;
+            - 非必要不使用嵌套列表;如需表达多层次内容,使用三级标题(###)加一级列表;
+            - 非必要不使用分行、分段、加粗、列表、标题以外的 Markdown 格式。
+
+            > 注意:以上格式要求仅限知识问答类问题。对于创作、数理逻辑、阅读理解等需求,或涉及安全敏感问题时,按惯常方式回答。若用户明确指定回复风格,优先满足用户需求。
+           """
+        system_prompt = {"role": "system", "content": [{"type": "input_text", "text": system_prompt}]}
+
+        api_messages = [system_prompt, {"role": latest_user_msg.role, "content": latest_user_msg.content}]
+        tools = get_web_search_tools()
         previous_response_id = get_previous_response_id(username, chatHistory)
 
         stream = client.responses.create(
@@ -40,21 +86,59 @@ async def generate_stream_response(request: ChatRequest, username: str):
             input=api_messages,
             tools=tools,
             stream=True,
-            store=True,
+            # store=True,
             previous_response_id=previous_response_id,
-            # thinking={"type": "auto"},
+            # thinking={"type": "auto"}, 不支持
         )
 
         accumulated_content = ""
         response_id = None
+        thinking_started = False
+        answering_started = False
 
+        print("=== 边想边搜启动 ===")
         for chunk in stream:
-            chunk_dict = chunk.__dict__ if hasattr(chunk, '__dict__') else {}
-            event_type = chunk_dict.get('type', '')
+            chunk_type = getattr(chunk, 'type', '')
 
-            if event_type in ('response.output_text.delta', 'response.doubao_app_call_output_text.delta'):
-                delta_text = chunk_dict.get('delta', '')
+            # ① 处理AI思考过程
+            if chunk_type == 'response.reasoning_summary_text.delta':
+                delta_text = getattr(chunk, 'delta', '')
                 if delta_text:
+                    if not thinking_started:
+                        # print(f"\n🤔 AI思考中 [{datetime.now().strftime('%H:%M:%S')}]:")
+                        thinking_started = True
+                    # print(delta_text, end='', flush=True)
+                    yield f"data: {StreamResponse(content=delta_text, finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='thinking').model_dump_json()}\n\n"
+
+            # ② 处理搜索状态
+            elif 'web_search_call' in chunk_type:
+                if 'in_progress' in chunk_type:
+                    print(f"\n\n🔍 开始搜索 [{datetime.now().strftime('%H:%M:%S')}]")
+                    _now_str = datetime.now().strftime("%H:%M:%S")
+                    yield f"data: {StreamResponse(content=f'开始搜索 [{_now_str}]', finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
+                elif 'completed' in chunk_type:
+                    print(f"\n✅ 搜索完成 [{datetime.now().strftime('%H:%M:%S')}]")
+                    _now_str = datetime.now().strftime("%H:%M:%S")
+                    yield f"data: {StreamResponse(content=f'搜索完成 [{_now_str}]', finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
+
+            # ③ 处理搜索关键词
+            elif (chunk_type == 'response.output_item.done'
+                  and hasattr(chunk, 'item')
+                  and str(getattr(chunk.item, 'id', '')).startswith('ws_')):
+                if hasattr(chunk.item, 'action') and hasattr(chunk.item.action, 'query'):
+                    query = chunk.item.action.query
+                    print(f"\n📝 本次搜索关键词:{query}")
+                    yield f"data: {StreamResponse(content=f'本次搜索关键词:{query}', finished=False, model=config.MODEL_NAME, timestamp=datetime.now(), type='searching').model_dump_json()}\n\n"
+
+            # ④ 处理最终回答文本(实时推送给前端)
+            elif chunk_type == 'response.output_text.delta':
+                delta_text = getattr(chunk, 'delta', '')
+                if delta_text:
+                    if not answering_started:
+                        print(f"\n\n💬 AI回答 [{datetime.now().strftime('%H:%M:%S')}]:")
+                        print("-" * 50)
+                        answering_started = True
+                    print(delta_text, end='', flush=True)
                     accumulated_content += delta_text
                     response_data = StreamResponse(
                         content=delta_text,
@@ -65,17 +149,21 @@ async def generate_stream_response(request: ChatRequest, username: str):
                     yield f"data: {response_data.model_dump_json()}\n\n"
                     await asyncio.sleep(0.01)
 
-            elif event_type == 'response.completed':
-                if 'response' in chunk_dict and hasattr(chunk_dict['response'], 'id'):
-                    response_id = chunk_dict['response'].id
+            # ⑤ 处理响应完成事件
+            elif chunk_type == 'response.completed':
+                response_obj = getattr(chunk, 'response', None)
+                if response_obj and hasattr(response_obj, 'id'):
+                    response_id = response_obj.id
                 save_chat_log(
                     username=username,
                     question=latest_user_msg.content,
                     stream_mode=True,
-                    raw_response=repr(chunk_dict.get('response')),
+                    raw_response=repr(response_obj),
                     status="success",
                 )
 
+        print(f"\n\n=== 边想边搜完成 [{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ===")
+
         if accumulated_content:
             chatHistory[username].append(ChatMessage(
                 role="assistant",
@@ -90,7 +178,7 @@ async def generate_stream_response(request: ChatRequest, username: str):
                 timestamp=datetime.now()
             )
             yield f"data: {final_response.model_dump_json()}\n\n"
-            print("流式内容已全部输出")
+
 
     except Exception as e:
         error_response = {
@@ -110,8 +198,8 @@ async def generate_stream_response(request: ChatRequest, username: str):
 
 @router.post("/chat", response_model=ChatResponse)
 async def chat(
-        request: ChatRequest,
-        username: Annotated[str, Depends(resolve_username)],
+    request: ChatRequest,
+    username: Annotated[str, Depends(resolve_username)],
 ):
     try:
         if username not in chatHistory:
@@ -237,7 +325,7 @@ async def get_models(current_user: Annotated[User, Depends(get_current_active_us
 
 @router.get("/history")
 async def get_user_history(
-        current_user: Annotated[User, Depends(get_current_active_user)]
+    current_user: Annotated[User, Depends(get_current_active_user)]
 ) -> List[ChatMessage]:
     username = current_user.username
     if username not in chatHistory:
@@ -254,7 +342,8 @@ async def clear_user_history(current_user: Annotated[User, Depends(get_current_a
     if username in chatHistory:
         message_count = len(chatHistory[username])
         del chatHistory[username]
-        return {"message": "聊天历史已清空", "user": username, "deleted_messages": message_count, "timestamp": datetime.now()}
+        return {"message": "聊天历史已清空", "user": username, "deleted_messages": message_count,
+                "timestamp": datetime.now()}
     return {"message": "用户没有聊天历史", "user": username, "deleted_messages": 0, "timestamp": datetime.now()}
 
 

+ 18 - 2
app/routers/chat_tools.py

@@ -2,9 +2,9 @@ from fastapi import APIRouter, HTTPException
 from datetime import datetime
 
 from ..core.ark_client import config, client
-from ..schemas.chat import ChatMessage, ChatResponse, CircleRequest
+from ..schemas.chat import ChatMessage, ChatResponse, CircleRequest, CirclePromptConfig
 from ..db.souyue_mongo import get_mblog_by_id
-from ..db.mongo import get_circle_prompt
+from ..db.mongo import get_circle_prompt, upsert_circle_prompt
 
 router = APIRouter()
 
@@ -15,6 +15,7 @@ def _build_prompt(product_text: str, prompt_config: dict) -> str:
     style = prompt_config.get("style", "自然亲切,有活人感")
     keywords: list = prompt_config.get("keywords") or []
     forbidden: list = prompt_config.get("forbidden") or []
+    extraInstruction = prompt_config.get("extra_instruction")
 
     lines = [
         f"你是{role},活跃在{name}兴趣圈。",
@@ -30,11 +31,26 @@ def _build_prompt(product_text: str, prompt_config: dict) -> str:
         lines.append(f"{seq}. 禁止使用以下词语:{', '.join(forbidden)}")
         seq += 1
     lines.append(f"{seq}. 语言自然,不要暴露你是AI")
+
+    # 额外要求
+    if extraInstruction:
+        lines.append(f"【额外要求】{','.join(extraInstruction)}")
+
     lines.append(f"\n帖子内容:{product_text}")
 
     return "\n".join(lines)
 
 
+# 存储/更新兴趣圈提示词模版(appName 已存在则覆盖)
+@router.post("/prompt")
+async def save_circle_prompt(promptcfg: CirclePromptConfig):
+    try:
+        upsert_circle_prompt(promptcfg.model_dump())
+        return {"message": "保存成功", "appName": promptcfg.appName}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
+
+
 # 评论帖子的马甲机器人,无状态,支持批量对多个帖子智能回复
 @router.post("/airesp", response_model=ChatResponse)
 async def generate_circle_comment(request: CircleRequest):

+ 16 - 2
app/schemas/chat.py

@@ -12,8 +12,20 @@ class ChatMessage(BaseModel):
 
 # 帖子请求
 class CircleRequest(BaseModel):
-    id: str #帖子的主键
+    id: str  # 帖子的主键
 
+
+# 兴趣圈提示词模版
+class CirclePromptConfig(BaseModel):
+    appName: str          # 包名,作为唯一标识
+    name: str             # 兴趣圈名称
+    role: str             # AI 扮演的角色
+    style: str            # 回复风格描述
+    keywords: List[str] = []   # 推荐使用的关键词
+    forbidden: List[str] = []  # 禁止使用的词语
+    extra_instruction: str    # 是给模型更细化的行为指引,可以包含在最终提示词中。
+
+# Ai的请求对象
 class ChatRequest(BaseModel):
     messages: List[ChatMessage]
     model: Optional[str] = config.MODEL_NAME
@@ -22,15 +34,17 @@ class ChatRequest(BaseModel):
     token: Optional[str] = None   # App 端传入的第三方 token
 
 
+# Ai的返回对象
 class ChatResponse(BaseModel):
     message: ChatMessage
     model: str
     usage: Optional[Dict[str, Any]] = None
     response_id: Optional[str] = None
 
-
+# 流式对象
 class StreamResponse(BaseModel):
     content: str
     finished: bool
     model: str
     timestamp: datetime
+    type: str = "answer"  # "thinking"=AI思考开过车delta | "searching"=搜索状态/关键词 | "answer" = 正式回答 delta(现有逻辑)

+ 15 - 0
app/utils/chat_utils.py

@@ -21,7 +21,22 @@ def get_previous_response_id(username: str, chat_history: dict) -> Optional[str]
                 return message.response_id
     return None
 
+# 联网搜索工具
+def get_web_search_tools() -> list:
+    return  [{
+        "type": "web_search",
+        "max_keyword": 20,
+        "limit": 20,
+        "sources": ["douyin", "moji", "toutiao"],# 附加搜索来源(抖音百科、墨迹天气、头条图文等平台)
+        "user_location": {  # 用户地理位置(用于优化搜索结果)
+            "type": "approximate",  # 大致位置
+            "country": "中国",
+            "region": "浙江",
+            "city": "杭州"
+        }
+    }]
 
+# 豆包助手工具
 def get_doubao_tools() -> list:
     return [{
         "type": "doubao_app",