💻 源码
from langchain_core.messages import HumanMessage

from _004_langgraph_more_nodes.agent_state import AgentState
from common.llm import my_llm
from common.config import Config

conf = Config()


def semantic_transcription_node(state: AgentState) -> AgentState:
    """
    语义转写节点:
    根据历史对话内容(history_messages)和当前用户输入(input),
    生成一个更清晰、标准化的语义转写结果,用于下游意图识别或问题理解。

    这是图里唯一读 state["input"](用户原话)的节点,往后所有节点读的都是
    本节点写出的 input_semantic_trans。因此这个键必须在每条路径上都写非空值 ——
    留空会让下游全部收到空输入,整张图返回空回答。
    """
    # 获取用户输入和历史上下文
    print("语义转写节点执行开始了")
    user_input = (state.get("input") or "").strip()
    history_messages = state.get("history_messages", [])
    print(f"历史消息:{history_messages}")

    # 本节点是每轮的第一个节点,也就是唯一能识别"新一轮开始"的地方,
    # 所以每轮只属于这一轮的键在这里清掉。
    # 加了 checkpointer 之后 state 会按 thread_id 跨轮保留,不清的话:
    #   - cypher_retry_count 只增不减,第 4 轮时已经到上限 3,这一轮的 cypher
    #     只要有一条不合法就直接放弃重试,额度被前几轮吃光了;
    #   - neo4j_answer / direct_out 只在各自那条分支写,走另一条分支时留着上一轮的值,
    #     路径判断(_summarize 用 neo4j_answer 判断走没走图谱)会看错;
    #   - output 万一哪条路径没写,会把上一轮的答案返回给这一轮的问题 —— 清空后
    #     退化成空串,_005_ 那边会正常兜底成错误文案。
    # history_messages 和 input_semantic_trans 不能清:前者就是跨轮记忆本身,后者下面就写。
    state["cypher_retry_count"] = 0
    for stale_key in (
        "neo4j_answer",
        "direct_out",
        "output",
        "cypher_query",
        "cypher_results",
        "is_all_validate_cypher",
    ):
        state[stale_key] = None

    # 空输入:没什么可转写的,也不必调大模型。下游靠 input_semantic_trans 判空短路
    if not user_input:
        state["input_semantic_trans"] = ""
        state["history_messages"] = history_messages
        print("语义转写节点:空输入,跳过")
        return state

    # 更新历史
    history_messages.append({"role": "user", "content": user_input})
    state["history_messages"] = history_messages
    # 只保留最近 history_num 轮。切片放在 append 之后、末尾去掉一条,
    # 去掉的正是刚加进去的这轮 —— 当前输入单独放在提示词的【当前用户输入】里,
    # 混进【历史对话】会让模型把它当成上一轮的话
    history_messages = history_messages[-(conf.history_num + 1):-1]

    # 第一轮没有历史,没有指代词可还原,转写只会把原问题改写一遍(甚至改坏),
    # 白花一次调用。直接用原话
    if not history_messages:
        state["input_semantic_trans"] = user_input
        print(f"语义转写节点:无历史,直接用原输入:{user_input}")
        return state

    # 构建提示词
    prompt = f"""
你是一名语言理解专家,负责将用户输入转写为清晰、简洁、语义明确的问题或意图表达。
你需要基于以下上下文(最近6轮对话),分析用户真正想表达的意思。
请不要编造不存在的信息,也不要直接回答问题,只需输出语义转写后的句子。

【历史对话】
{history_messages}

【当前用户输入】
{user_input}

请输出:
1. 一句自然语言,清楚表达用户当前真正的问题或意图;
2. 不要包含解释性文字;
3. 如果用户输入含糊不清,请基于上下文合理补全语义;
4. 输出格式:直接输出转写结果,无需加前缀。
"""

    # 调用大模型
    response = my_llm.invoke([HumanMessage(content=prompt)])
    result = response.content.strip()

    # 模型偶尔会返回空串。下游全靠这个键,空了等于整张图收不到输入,所以退回原话
    if not result:
        result = user_input
        print("语义转写节点:模型返回空,退回原输入")

    # 保存语义转写结果
    state["input_semantic_trans"] = result
    print(f"完成生成语义转写:{result}")
    return state


if __name__ == '__main__':
    # 每条覆盖一种情况:有历史要还原指代 / 中医多轮 / 无历史 / 空输入
    cases = [
        {
            "input": "那第二呢?",
            "history_messages": [
                {"role": "user", "content": "世界第一高峰是什么?"},
                {"role": "assistant", "content": "世界第一高峰是珠穆朗玛峰。"},
            ],
        },
        {
            "input": "它有什么副作用?",
            "history_messages": [
                {"role": "user", "content": "人参有什么功效?"},
                {"role": "assistant", "content": "人参能大补元气、复脉固脱、补脾益肺。"},
            ],
        },
        # 无历史:预期原样返回,不调用大模型
        {"input": "人参有什么功效?", "history_messages": []},
        # 空输入:预期空串,不调用大模型
        {"input": "", "history_messages": []},
    ]

    for case in cases:
        result_state = semantic_transcription_node(dict(case))
        print(f"\n原输入: {case['input']!r}")
        print(f"  转写后: {result_state['input_semantic_trans']!r}")
        print(f"  历史轮数: {len(result_state['history_messages'])}")