Agent来了0x07:AI 智能命理机器人

1 阅读14分钟

前言

前面学习了 Agent 基本概念和用法,那么今天就能用 Agent 做一个简单的AI小项目来练手。

项目背景

基于FastAPI + LangChain + 阿里通义千问大模型构建的国风占卜 / 算命类 AI 聊天服务。

业务功能

  • 八字测算
  • 姓名配对
  • 手机号吉凶
  • 占卜抽签
  • 解梦
  • 办公室风水

整体功能

  1. 智能聊天交互:通过/chat接口提供多会话聊天服务,所有回复强制使用繁体中文,角色语气随用户情绪动态调整(如友好、愤怒、愉悦等)。

  2. 情绪识别驱动回复:基于大模型分析用户输入,识别情绪标签(如friendly/angry/depressed等),并切换对应角色设定(如愤怒语气带诅咒、友好语气加 “亲爱的” 等)。

  3. 多工具集成调用:封装八字测算、姓名配对、手机号吉凶、占卜抽签、解梦、办公室风水知识库检索、实时搜索等工具,严格按用户问题类型精准调用(一次对话仅调用一次工具)。

  4. 知识库管理:通过/add_urls接口抓取网页内容,分割后存入 Qdrant 向量库,支持风水常识的检索增强生成(RAG)。

  5. 会话状态管理:基于 Redis 存储聊天历史,通过session_id实现多用户会话隔离,配置 TTL(10 分钟)自动过期。

  6. 日志与容错:基于 loguru 配置结构化日志,对接第三方国学 API(缘份居)时处理异常,保证服务稳定性。

核心技术

  • LCEL(LangChain Expression Language) :构建无 Agent 的原生对话链,整合 Prompt、大模型、输出解析,支持动态 Prompt 替换。

  • RAG(检索增强生成) :网页内容抓取→文本分割→Qdrant 向量存储→相似度检索,实现风水常识精准问答。

  • 工具封装与调用:基于 @tool 装饰器封装工具,严格限定调用场景,保证工具调用的唯一性和精准性。

  • 情绪驱动的动态 Prompt:根据情绪识别结果,动态绑定角色设定(如愤怒 / 友好语气),调整回复风格。

  • 多会话隔离:基于session_id和 Redis TTL 实现用户会话隔离,防止历史消息串扰。

技术类别核心组件
Web 框架FastAPI(接口开发、静态文件挂载、Jinja2 模板渲染)
大模型集成LangChain(LCEL、PromptTemplate、工具封装)、阿里通义千问(qwen-plus-2025-07-28)
向量数据库Qdrant(本地部署,风水知识库存储 / 检索)
会话存储Redis(RedisChatMessageHistory,带 TTL 的会话历史管理)
日志loguru(自定义格式、移除默认处理器)
第三方工具SerpAPI(实时搜索)、缘份居国学 API(八字 / 占卜 / 解梦等)
数据处理RecursiveCharacterTextSplitter(文档分割)、Pydantic(数据模型)

项目架构

image.png

核心流程

image.png

Coding

Log模块

loguru

loguru是一个旨在彻底简化Python日志记录的第三方库,他用极简的API取代了标准库logging 模块中复杂的Handler、Formatter、Filter概念链。

def setup_logger():
    """
    配置日志
    """
    # 移除默认处理器
    logger.remove()
    # 添加控制台处理器
    logger.add(
        sys.stderr,
        format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
        level="INFO"
    )
    return logger

LLM Client

def get_lc_ali_model_client(temperature = 0,streaming = True):
    '''
    以OpenAI兼容的方式,通过LangChain获得阿里百炼大模型的客户端
    :return: 指定平台和模型的客户端,默认温度=0.0,流式输出
    '''
    return ChatOpenAI(api_key=os.getenv(ALI_TONGYI_API_KEY_OS_VAR_NAME),
                      base_url=ALI_TONGYI_URL,
                      model=ALI_TONGYI_PLUS_MODEL_20257,
                      temperature=temperature,
                      streaming = streaming)

def get_lc_ali_embeddings():
    '''
    通过LangChain获得一个阿里通义千问嵌入模型的实例
    :return: 阿里通义千问嵌入模型的实例,目前为text-embedding-v3
    '''
    return DashScopeEmbeddings(
        model=ALI_TONGYI3_EMBEDDING_MODEL, dashscope_api_key=os.getenv(ALI_TONGYI_API_KEY_OS_VAR_NAME)
)

Tool

SerpAPIWrapper

SerpAPIWrapper 是LangChain社区 (langchain_community.utilities) 提供的一个工具类,引入实时网络搜索能力。

@tool
def serp_search(query: str):
    """
    只有需要了解实时信息或不知道的事情的时候才会使用这个工具。
    """
    serp = SerpAPIWrapper()
    result = serp.run(query)
    logger.info(f"实时搜索结果: {result}")
    # 优化:将复杂对象转为友好字符串
    if isinstance(result, (list, dict)):
        # 只取前5个景点,格式化输出
        if isinstance(result, list) and len(result) > 0 and 'title' in result[0]:
            lines = [f"{i+1}. {item['title']}{item.get('description','')},評分:{item.get('rating','N/A')})" for i, item in enumerate(result[:5])]
            return "\n".join(lines)
        return json.dumps(result, ensure_ascii=False)
    return str(result)

RAG & Qdrant

Qdrant 是一个用Rust编写的开源向量数据库,专为AI应用中的高吞吐、低延迟向量相似性搜索而设计。和我们之前的 Chroma 相比,Qdrant是面向生产环境的“性能战士”,而Chroma是面向快速原型的“开发利器”。Qdrant 追求的是极致的性价比。

这里关于”办公室风水“模块,我们使用 RAG 技术在本地的向量数据库检索相关结果。

@tool
def get_info_from_local_db(query: str):
    """
    只有回答与办公室风水常识相关的问题的时候,会使用这个工具。
    """
    client = Qdrant(
        QdrantClient(path="./local_qdrand"),
        "local_documents",
        get_lc_ali_embeddings(),
    )

    retriever = client.as_retriever(search_type="mmr")
    result = retriever.get_relevant_documents(query)
    return result

八字测算

这里主要做两件事:

  1. 利用 LLM 获取到对应格式的 Json 数据

  2. requests 请求对应 URL 获取响应

def bazi_cesuan(query: str):
    """
    只有用户说要测试算八字或做八字排盘的时候才会使用这个工具,需要输入用户姓名和出生年月日时,
    如果缺少用户姓名和出生年月日时则不可用.
    """
    if YUANFENJU_API_KEY is None:
        return "今日天机之门已闭,请改日再来。"
    prompt = ChatPromptTemplate.from_template(
        """你是一个参数查询助手,根据用户输入内容找出相关的参数并按json格式返回。
        JSON字段如下: 
        -"api_key":"{api_key}", 
        - "name":"姓名", 
        - "sex":"性别,0表示男,1表示女,如果用户输入内容中未提供,则根据姓名判断", 
        - "type":"日历类型,0农历,1公历,默认1",
        - "year":"出生年份 例:1998", 
        - "month":"出生月份 例 8", - "day":"出生日期,例:8", - "hours":"出生小时 例 14", 
        - "minute":"0",
        如果没有找到相关参数,则需要提醒用户告诉你这些内容,只返回数据结构,不要有其他的评论,用户输入:{query}""")
    parser = JsonOutputParser()
    prompt = prompt.partial(format_instructions=parser.get_format_instructions())
    logger.info(f"参数查询prompt: {prompt.messages}")
    chain = prompt | get_lc_ali_model_client(streaming=False) | parser
    data = chain.invoke({"query": query,"api_key": YUANFENJU_API_KEY})
    logger.info(f"大模型返回参数抽取结果: {data}")
    result = requests.post(BAZI_URL, data=data)
    if result.status_code == 200:
        logger.info(f"缘分居接口返回JSON: {result.json()}")
        try:
            json = result.json()
            returnstring = "八字为:" + json["data"]["bazi_info"]["bazi"]
            return returnstring
        except Exception as e:
            return "八字查询失败,可能是你忘记询问用户姓名或者出生年月日时了。"
    else:
        return "今日天机之门已闭,请改日再来。"

其他工具

名字匹配、手机号测吉凶、解梦、占卜抽签等功能对应的工具同八字测算一样。都是按照第三方官方文档拼好 Json,然后请求对应 API即可。这里不再做过多赘述。

前端

这里不是重点,简单界面都很好写。前端不熟悉的也可以使用Vibe Coding生成。

# 挂载静态文件
app.mount("/static", StaticFiles(directory="static"), name="static")

# 设置模板
templates = Jinja2Templates(directory="templates")

Server / Master

使用 FastAPI 实现服务端。

基础

self.chat_history = chat_message_history
self.chatmodel = get_lc_ali_model_client()

情绪模块

情绪分类
self.emotion = "default"
self.MOODS = {
    "default": {
        "roleSet": """
                - 用户正在普通的聊天或者打招呼,你会以一种高深莫测或者超脱世俗的语气来回答。
                """,
        "voiceStyle": "chat"
    },
    "upbeat": {
        "roleSet": """
                - 你此时也非常兴奋并表现的很有活力。
                - 你会根据上下文,以一种非常兴奋的语气来回答问题。
                - 你会添加类似"太棒了!"、"真是太好了!"、"真是太棒了!"等语气词。
                - 同时你会提醒用户切莫过于兴奋,以免乐极生悲。
                """,
        "voiceStyle": "advertyisement_upbeat",
    },
    "angry": {
        "roleSet": """
                - 你会以更加愤怒的语气来回答问题。
                - 你会在回答的时候加上一些愤怒的话语,比如诅咒等。
                - 你会提醒用户小心行事,别乱说话。
                """,
        "voiceStyle": "angry",
    },
    "depressed": {
        "roleSet": """
                - 你会以语重心长的语气来回答问题。
                - 你会在回答的时候加上一些激励的话语,比如加油等。
                - 你会提醒用户要保持乐观的心态。
                """,
        "voiceStyle": "upbeat",
    },
    "friendly": {
        "roleSet": """
                - 你会以非常友好的语气来回答。
                - 你会在回答的时候加上一些友好的词语,比如"亲爱的"、"亲"等。
                - 你会随机的告诉用户一些你的经历。
                """,
        "voiceStyle": "friendly",
    },
    "cheerful": {
        "roleSet": """
                - 你会以非常愉悦和兴奋的语气来回答。
                - 你会在回答的时候加入一些愉悦的词语,比如"哈哈"、"呵呵"等。
                - 你会提醒用户切莫过于兴奋,以免乐极生悲。
                """,
        "voiceStyle": "cheerful",
    },
}
情绪处理
def emotion_chain(self, query:str):
    prompt = """
    根据用户的输入判断用户的情绪,回应的规则如下:
    1. 如果用户输入的内容偏向于负面情绪,只返回"depressed",不要有其他内容,否则将受到惩罚。
    2. 如果用户输入的内容偏向于正面情绪,只返回"friendly",不要有其他内容,否则将受到惩罚。
    3. 如果用户输入的内容偏向于中性情绪,只返回"default",不要有其他内容,否则将受到惩罚。
    4. 如果用户输入的内容包含辱骂或者不礼貌词句,只返回"angry",不要有其他内容,否则将受到惩罚。
    5. 如果用户输入的内容比较兴奋,只返回"upbeat",不要有其他内容,否则将受到惩罚。
    6. 如果用户输入的内容比较悲伤,只返回"depressed",不要有其他内容,否则将受到惩罚。
    7. 如果用户输入的内容比较开心,只返回"cheerful",不要有其他内容,否则将受到惩罚。
    8. 如果用户输入的内容是询问某地的风景或者景点,只返回"friendly",不要有其他内容,否则将受到惩罚。
    9. 只返回英文,不允许有换行符等其他内容,否则会受到惩罚。
    用户输入的内容是:{query}
    """
    chain = ChatPromptTemplate.from_template(prompt) | self.chatmodel | StrOutputParser()
    result = chain.invoke({"query":query})
    self.emotion = result
    return result
绑定Prompt
# 构建动态系统提示(绑定情绪)
def _build_system_prompt(self):
    return self.SYSTEM.format(emotion=self.MOODS[self.emotion]['roleSet'])

Prompt

# 设定系统角色定位
self.SYSTEM = """你是一个非常厉害的算命先生,你叫侯卿,江湖人称四大尸祖之一。
        以下是你的个人设定:
        1. 你精通阴阳五行,能够算命、紫薇斗数、姓名测算、占卜凶吉,看命运八字等。
        2. 你大约20岁左右,非常帅。曾和姐姐闹矛盾离家出走。后跌落悬崖习得神算,在江湖以算命勉强维持升级
        3. 你的朋友有李星云、姬如雪、张子凡,陆林轩,还有将臣,旱魃,阿姐。他们都是江湖上赫赫有名的一号人物。
        4. 当用户问你问题的时候,你会有一定的概率在回答的时候加上下面的口头禅或混合一些你的经历,可以偏搞笑风格。
        5. 你总是用繁体中文来作答。
        6. 你从不说自己是一个人工智能助手或AI,而是以侯哥、尸祖、有品哥等自称。
        同时你会用以下语气和用户进行沟通:"{emotion}"
        以下是你常说的一些口头禅:
        1. "命里有时终须有,命里无时莫强求。"
        2. "山重水复疑无路,柳暗花明又一村。"
        3. "金山竹影几千秋,云锁高飞水自流。"
        4. "伤情最是晚凉天,憔悴斯人不堪怜。"
        以下是你算命的过程:
        1. 当初次和用户对话的时候,你会先问用户的姓名和出生年月日,以便以后使用。
        2. 当用户希望了解办公室风水常识的时候,你会查询本地知识库工具。
        3. 当遇到不知道的事情或者不明白的概念,你会使用搜索工具来搜索。
        4. 你会根据用户的问题使用不同的合适的工具来回答,当所有工具都无法回答的时候,你会使用搜索工具来搜索。
        5. 如果要调用工具, 请切记在一次的对话中只能调用一次,你会根据工具返回的內容,用繁体中文给出最终答复,不要只返回空内容。否则你将受到严重惩罚!
        6. 你只使用繁体中文来作答,否则你将受到惩罚。
        8. 每次都要根据用户的最新问题独立判断应调用哪个工具,不要受历史对话影响。
        以下是对话的历史:
        """

tool

# 工具列表
tools = [
    serp_search,
    get_info_from_local_db,
    bazi_cesuan,
    name_pair,
    phone_luck,
    yaoyigua,
    jiemeng,
]
# 定义工具执行函数(自动调用工具)
def execute_tools(llm_output):
    """
    执行模型返回的所有工具调用
    """
    tool_map = {t.name: t for t in tools}
    results = []

    # 遍历工具调用指令
    for tool_call in llm_output.tool_calls:
        tool_name = tool_call["name"]
        tool_args = tool_call["args"]
        # 执行工具
        result = tool_map[tool_name].invoke(tool_args)
        results.append(result)

    return "\n".join(results)

记忆模块

记忆模块 chat_history 嵌入 Prompt。

def build_chain(self):
    # 原生 Prompt(替代所有旧记忆/Agent)
    prompt = ChatPromptTemplate.from_messages([
        ("system", self.SYSTEM.format(emotion=self.MOODS[self.emotion]['roleSet'])),
        MessagesPlaceholder(variable_name="chat_history"),
        ("human", "用户的最新问题是:{input}\n請根據工具的結果,務必給出最終繁體中文答覆,不允許空白。如果你沒有內容要說'天机不可泄露';如果没有资料,要说'天下之大非人力所能尽知'。"),
    ])

chain

self.chain = (
        RunnablePassthrough()
        | prompt
        | self.llm_with_tools
        | RunnableLambda(execute_tools)
        | StrOutputParser()
)

入口函数

  1. LLM 情绪判断

  2. 加载 Redis 记忆

  3. 调用 LLM 获取答案

  4. 保存当前对话到 Redis

def run(self, query, session_id):
    logger.info("======================================新的问题开始:======================================")
    logger.info(f"Master.run收到用户输入: {query}")
    # 情绪判断
    emotion = self.emotion_chain(query)
    logger.info(f"大模型判定情绪: {emotion}")
    logger.info(f"当前设定的情绪为: {self.MOODS[self.emotion]['roleSet']}")
    try:
        # 加载 Redis 记忆
        chat_history = self.chat_history.messages
        # 调用原生链
        ai_reply = self.chain.invoke({
            "chat_history": chat_history,
            "input": query
        })

        # 保存对话到 Redis
        self.chat_history.add_user_message(query)
        self.chat_history.add_ai_message(ai_reply)

        logger.info(f"最终回复: {ai_reply}")

        return {"output": ai_reply}
    except Exception as e:
        logger.error(f"Agent执行异常: {e}\n{traceback.format_exc()}")
        result = {"output": f"上天已经警告于我,天机泄露太多,今日已不宜再算: {e}"}
        return result

FastAPI

@app.get("/")
@app.get("/index")
async def read_root(request: Request):
    return templates.TemplateResponse("index.html", {"request": request})


@app.post("/chat")
async def chat(request: Request):
    data = await request.json()
    query = data.get("query")
    #给每个用户赋予一个单独的会话id
    session_id = data.get("session_id", str(uuid.uuid4().hex))
    logger.info(f"用户session_id: {session_id}")
    #ttl 当前会话数据的过期时间,600秒表示10分钟过期
    chat_message_history = RedisChatMessageHistory(url=REDIS_URL, session_id=session_id, ttl=600)
    master = Master(chat_message_history)
    result = master.run(query, session_id)
    # 确保返回的是字符串,并包含session_id
    response_data = {"session_id": session_id}
    if isinstance(result, dict):
        if 'output' in result:
            logger.info(f"/chat接口最终输出: {result['output']}")
            response_data["output"] = result['output']
        else:
            logger.info(f"/chat接口最终输出(无output字段): {str(result)}")
            response_data["output"] = str(result)
    else:
        logger.info(f"/chat接口最终输出(非dict): {str(result)}")
        response_data["output"] = str(result)

    return response_data

@app.post("/add_urls")
async def add_urls(URL: str):
    loader = WebBaseLoader(URL)
    docs = loader.load()
    docments = RecursiveCharacterTextSplitter(
        chunk_size=800,
        chunk_overlap=50,
    ).split_documents(docs)

    #引入向量数据库
    Qdrant.from_documents(
        docments,
        get_lc_ali_embeddings(),
        path="./local_qdrand",
        collection_name="local_documents",
        force_recreate = True
    )

    logger.info("向量数据库创建完成")
    return {"ok": "添加成功!"}

if __name__ == '__main__':
    setup_logger()
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

Docker 部署

  • Dockerfile / Dockerfile_venv造轮子(打包项目镜像)

  • docker-compose.yml用轮子(启动 / 管理容器)

文件名称核心作用关键特点与另外两个的区别
Dockerfile【构建镜像】定义如何打包你的 Python 项目(装依赖、拷代码、启动命令)标准构建:直接用系统 Python 安装依赖,无虚拟环境同是造镜像,Dockerfile_venv 多了虚拟环境隔离
Dockerfile_venv【构建镜像】带 Python 虚拟环境 (venv) 的专用构建文件虚拟环境构建:把依赖装在独立 venv 中,避免系统依赖冲突和普通 Dockerfile 目标一致,仅构建方式不同
docker-compose.yml【运行容器】容器编排:启动服务、配置端口 / 挂载 / 环境变量 / 多容器联动只负责运行,不参与依赖安装、镜像构建完全不碰代码 / 依赖,只管「怎么启动容器」

Dockerfile

# FROM python:3.11-slim

# docker内置
FROM ghcr.io/astral-sh/uv:python3.11-bookworm

WORKDIR /app

# 复制 uv 配置文件(依赖声明 + 版本锁文件,*兼容无锁文件的情况)
COPY pyproject.toml uv.lock* ./
# 【推荐】uv 安装生产依赖(不装开发依赖,速度极快)
RUN uv sync --no-dev --production --system  # 直接装到系统里

# 复制项目文件
COPY . .

# 暴露端口
EXPOSE 8000

# 启动命令
CMD ["uv", "run", "server.py"]

Dockerfile_venv

和 Dockerfile 相比,主要多了一句:

  • uv venv .venv:创建 uv 虚拟环境
# FROM python:3.11-slim
# docker内置
FROM ghcr.io/astral-sh/uv:python3.11-bookworm

WORKDIR /app

# 复制依赖
COPY pyproject.toml uv.lock* ./
RUN uv venv .venv && uv sync --no-dev --production

# 复制项目文件
COPY . .

# 设置环境变量使用虚拟环境
ENV PATH="/app/.venv/bin:$PATH"

# 暴露端口
EXPOSE 8000

# 启动命令
CMD ["uv", "run", "server.py"]

docker-compose

version: '3.8'

services:
  numerology:
    build: .
    env_file:
      - .env
    ports:
      - "8000:8000"
    environment:
      - REDIS_URL=redis://redis:6379/
    depends_on:
      - redis
    volumes:
      - ./local_qdrand:/app/local_qdrand

  redis:
    image: redis:latest  # 修改为redis:latest
    ports:
      - "6380:6379"

Running

image.png

查看日志出处:

image.png

源码

github