AI 电子伴侣聊天中的意图识别:用 LangChain 和 LangGraph 落地
在 AI 电子伴侣产品里,用户发来的一句话往往不只是问问题。同样一句我今天好累,可能是在求安慰,也可能只是想继续聊下去;可能是在表达现实生活里的压力,也可能希望 Agent 主动关心一下,甚至希望系统记住自己最近的状态。
如果聊天系统直接把用户输入丢给大模型,模型通常也能回复,但回复策略会摇摆不定。有时像客服,有时像心理咨询,有时又急着给一大段建议。对 AI 电子伴侣来说,我们可以在真正回复之前先做一层意图识别,把用户当前更需要什么分析出来,再把这个结果作为隐藏策略注入最终回复。
这篇文章我们就把这套已经落地的实现梳理一下:在 API 子站聊天接口中,用 LangChain 结构化输出 做意图分类,用 LangGraph 编排意图判断流程,把结果写入聊天消息 metadata_json,最后再注入到 Agent 回复 prompt 中。
这次不是要重做一个复杂的智能体系统,而是在现有聊天链路里补一层轻量、稳定的对话理解。请求进来以后,危险内容先由安全边界拦住;安全通过的消息再进入 LangGraph 意图识别流程;模型通过 LangChain withStructuredOutput 输出稳定 JSON;识别结果既会写入用户消息 metadata_json,方便后续分析和调试,也会进入最终聊天 prompt,让 Agent 的回应更贴近陪伴场景。
整体链路可以这样看:
01flowchart TD02A["用户发送消息"] --> B["加载 Agent 信息、历史消息、长期记忆"]03B --> C["安全边界判断"]04C --> D{"是否需要拒绝或危机回复"}05D -- "是" --> E["返回边界回复并落库"]06D -- "否" --> F["LangGraph 意图判断"]07F --> G["写入用户消息 metadata"]08G --> H["组装最终聊天 prompt"]09H --> I["调用 LLM 流式回复"]10I --> J["保存 assistant 消息"]11J --> K["后续记忆抽取"]
安全边界和意图判断解决的是两类问题。
安全边界先判断的是:这句话能不能继续聊,应该怎么限制。例如自伤危机、违法请求、隐私侵犯、操控关系等场景,系统必须先保护用户和他人。
意图判断再分析的是:这句话希望 Agent 怎样回应。例如用户是想要安慰、建议、暧昧互动、角色扮演,还是想调整 Agent 的说话方式。
所以落地顺序是:
refuse 或 crisis_support,直接返回边界回复。这样处理可以避免一个真实问题:当用户输入本身高风险时,系统还在认真分析用户是不是想要情绪陪伴,从而冲淡安全策略。
API 子站新增了 LangGraph 依赖:
1{2"dependencies": {3"@langchain/core": "^1.1.48",4"@langchain/langgraph": "^1.4.1",5"@langchain/openai": "^1.4.7"6}7}
这三个包的分工很清楚。@langchain/core 提供 prompt、runnable 等基础能力;@langchain/openai 负责兼容 OpenAI 协议和 Responses 协议的聊天模型调用;@langchain/langgraph 则负责把意图判断拆成更容易维护的图节点。
本次没有新增 D1 迁移,因为之前聊天消息表已经有 metadata_json 字段,可以直接保存分析结果。
AI 电子伴侣不是客服系统,因此意图分类不能停留在问答、投诉、咨询这种传统分类上。我们定义了一组更贴近陪伴、交友、恋爱互动的意图:
01const CompanionIntentPrimarySchema = z.enum([02'casual_chat',03'emotional_support',04'relationship_advice',05'romantic_flirt',06'companionship_presence',07'roleplay',08'life_sharing',09'memory_update',10'preference_setting',11'agent_feedback',12'conversation_repair',13'date_or_activity_planning',14'creative_request',15'meta_question',16'unclear',17])
为了方便后面使用,我们可以先按用途理解这些意图:
casual_chat、companionship_presence、life_sharingemotional_support、conversation_repairrelationship_advice、romantic_flirtroleplay、creative_requestmemory_update、preference_setting、agent_feedbackmeta_question、unclear这里要特别小心:不要把所有带情绪的输入都分类成建议。很多时候用户并不想听解决方案,只是想被接住。
意图判断不能只返回一个字符串,否则后面很难稳定使用。最终使用 Zod 定义完整结构:
01const ConversationIntentSchema = z.object({02primary: CompanionIntentPrimarySchema,03secondary: z.array(CompanionIntentPrimarySchema).max(3),04confidence: z.number().min(0).max(1),05userNeed: z.enum([06'be_heard',07'be_comforted',08'get_advice',09'get_reply_draft',10'play_along',11'feel_connected',12'set_boundary',13'update_memory',14'adjust_agent',15'unknown',16]),17requestedAgentAction: z.enum([18'answer_directly',19'comfort_first',20'ask_follow_up',21'draft_message',22'analyze_situation',23'roleplay_response',24'remember_fact',25'adjust_style',26'repair_misunderstanding',27'continue_topic',28]),29relationshipSignal: z.enum([30'neutral',31'warming_up',32'seeking_closeness',33'testing_boundary',34'feeling_hurt',35'pulling_away',36'dependency_risk',37'conflict',38]),39replyExpectation: z.object({40depth: z.enum(['short', 'medium', 'deep']),41warmth: z.enum(['low', 'medium', 'high']),42directness: z.enum(['gentle', 'balanced', 'direct']),43shouldAskQuestion: z.boolean(),44}),45shouldClarify: z.boolean(),46clarifyingQuestion: z.string().trim().max(200).nullable(),47promptGuidance: z.string().trim().max(600),48})
这个结构里不能只看 primary。userNeed 记录用户真正想被如何对待,requestedAgentAction 记录 Agent 应该采取什么回复动作,relationshipSignal 描述当前关系氛围是升温、疏远、冲突,还是依赖风险。replyExpectation 用来控制回复长度、温度和直接程度,promptGuidance 则给最终回复模型一段简短策略。
所以意图判断不只是打标签,它要直接服务后面的回复生成。
意图判断的 prompt 会先把职责讲清楚:你不是来聊天的,而是来分析用户意图的。
核心 prompt 如下:
01const conversationIntentPrompt = ChatPromptTemplate.fromMessages([02[03'system',04[05'你是 AI 电子伴侣聊天产品的意图识别器。',06'你的任务不是回复用户,而是判断用户在当前亲密陪伴/交友聊天场景中的真实沟通意图。',07'必须结合最近对话、长期记忆、Agent 人设边界和安全边界结果来判断。',08'优先区分:普通闲聊、情绪陪伴、恋爱暧昧、角色扮演、生活分享、关系建议、记忆更新、偏好设置、对 Agent 的反馈、误会修复。',09'不要把所有问题都归为关系建议;用户只是想被陪伴、被听见或维持互动时,要识别为陪伴类意图。',10'当用户表达模糊但情绪明确时,先判断情绪和期待,再决定是否需要追问。',11'输出必须是可被 LangChain 结构化解析的 JSON 对象。',12].join('\n'),13],14[15'human',16[17'Agent 名称:{agentName}',18'',19'Agent 自定义边界规则:',20'{agentGuardrails}',21'',22'安全边界判断:',23'{safety}',24'',25'长期记忆:',26'{activeMemories}',27'',28'最近对话:',29'{recentMessages}',30'',31'本轮用户输入:',32'{userText}',33].join('\n'),34],35])
这里输入的不只有本轮用户文本,还包括 Agent 名称、Agent 自定义边界规则、当前安全边界结果、当前用户和 Agent 的长期记忆,以及最近聊天消息。上下文越完整,意图判断越不容易只看字面意思。
为什么要把安全边界结果也传进去?因为有些意图和风险是交织在一起的。例如用户明显出现强情绪依赖时,意图可能仍然是寻求陪伴,但最终回复不能强化依赖,需要更克制。
意图判断调用模型时,没有让模型自由输出文本,而是使用 withStructuredOutput:
01async function invokeConversationIntentAnalysis(params: {02method: LangChainStructuredOutputMethod03providerConfig: ChatProviderConfig04agentName: string05agentGuardrails: string | null06safety: ConversationSafety07activeMemories: StoredAgentMemory[]08recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>09userText: string10signal?: AbortSignal11}) {12const model = buildLangChainChatModel(params.providerConfig)13const structuredModel = model.withStructuredOutput(ConversationIntentSchema, {14name: 'conversation_intent_analysis',15method: params.method,16})17const chain = conversationIntentPrompt.pipe(structuredModel)1819const result = await chain.invoke({20agentName: params.agentName || '未命名 Agent',21agentGuardrails: params.agentGuardrails || '暂无',22safety: formatSafetyForPrompt(params.safety),23activeMemories: formatExistingMemories(params.activeMemories),24recentMessages: formatRecentMessages(params.recentMessages),25userText: params.userText,26}, params.signal ? { signal: params.signal } : undefined)2728return normalizeConversationIntent(ConversationIntentSchema.parse(result), params.safety)29}
这里我们可以多看两个细节。
第一,withStructuredOutput 仍然可能因为不同模型、不同中转协议支持度不同而失败,所以外层做了多方法重试。
1function getStructuredOutputMethods(providerConfig: ChatProviderConfig) {2return providerConfig.wireApi === 'responses'3? ['jsonSchema', 'functionCalling', 'jsonMode'] as const4: ['functionCalling', 'jsonSchema', 'jsonMode'] as const5}
Responses 协议优先尝试 jsonSchema,Chat Completions 协议优先尝试 functionCalling。这样对不同三方中转 API 更友好。
第二,结构化输出之后仍然要再走一层 normalizeConversationIntent。不要完全相信模型输出,尤其是涉及置信度、追问和依赖风险时,代码层要继续兜底。
这次 LangGraph 先没有做复杂多分支,而是从一个轻量图开始:
01const ConversationIntentState = Annotation.Root({02providerConfig: Annotation<ChatProviderConfig>(),03agentName: Annotation<string>(),04agentGuardrails: Annotation<string | null>(),05safety: Annotation<ConversationSafety>(),06activeMemories: Annotation<StoredAgentMemory[]>(),07recentMessages: Annotation<Array<{ role: 'user' | 'assistant'; content: string }>>(),08userText: Annotation<string>(),09normalizedInput: Annotation<string>(),10intent: Annotation<ConversationIntent | null>(),11signal: Annotation<AbortSignal | undefined>(),12})
图里目前只有两个节点。
第一个节点负责归一化用户输入:
1function normalizeIntentInputNode(state: typeof ConversationIntentState.State) {2return {3normalizedInput: normalizeStoredMessage(state.userText),4}5}
第二个节点负责调用 LangChain 意图识别:
01async function classifyIntentNode(state: typeof ConversationIntentState.State) {02const userText = state.normalizedInput || normalizeStoredMessage(state.userText)0304if (!userText) {05return {06intent: normalizeConversationIntent({07...fallbackIntent,08primary: 'casual_chat',09confidence: 0.7,10userNeed: 'feel_connected',11requestedAgentAction: 'continue_topic',12shouldClarify: false,13clarifyingQuestion: null,14promptGuidance: '用户没有提供明确新内容时,轻柔延续当前话题,不要制造压力。',15}, state.safety),16}17}1819return {20intent: await classifyConversationIntentWithLangChain({21providerConfig: state.providerConfig,22agentName: state.agentName,23agentGuardrails: state.agentGuardrails,24safety: state.safety,25activeMemories: state.activeMemories,26recentMessages: state.recentMessages,27userText,28signal: state.signal,29}),30}31}
最后把图编译出来:
1const conversationIntentGraph = new StateGraph(ConversationIntentState)2.addNode('normalizeInput', normalizeIntentInputNode)3.addNode('classifyIntent', classifyIntentNode)4.addEdge(START, 'normalizeInput')5.addEdge('normalizeInput', 'classifyIntent')6.addEdge('classifyIntent', END)7.compile()
这里可能会有一个疑问:只有两个节点,为什么还要用 LangGraph?
原因是这个位置后面很容易继续长大。比如情绪识别、回复策略路由、是否需要主动写入长期记忆、是否需要调用工具、是否进入角色扮演模式、是否触发关系阶段变化,这些判断都可能陆续接进来。
如果现在只是写一个普通函数,后面很容易堆成一大段难维护的条件判断。我们先用 LangGraph 把状态和节点边界定义好,后续扩展时就可以自然增加节点,而不用重写聊天链路。
意图判断不能影响主聊天可用性。也就是说,哪怕意图分析失败,聊天也应该继续。
所以实现里定义了 fallbackIntent:
01const fallbackIntent: ConversationIntent = {02primary: 'unclear',03secondary: [],04confidence: 0.3,05userNeed: 'unknown',06requestedAgentAction: 'ask_follow_up',07relationshipSignal: 'neutral',08replyExpectation: {09depth: 'medium',10warmth: 'medium',11directness: 'gentle',12shouldAskQuestion: true,13},14shouldClarify: true,15clarifyingQuestion: '你是更想让我先听你说说,还是想让我帮你一起想办法?',16promptGuidance: '先简短承接用户,不要擅自下结论;用一个自然的问题澄清用户真正需要。',17}
当 LangChain 结构化输出失败,或者 LangGraph 执行异常时,就回到这个保守策略。
01async function analyzeConversationIntent(params: {02providerConfig: ChatProviderConfig03agentName: string04agentGuardrails: string | null05safety: ConversationSafety06activeMemories: StoredAgentMemory[]07recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>08userText: string09signal: AbortSignal10}): Promise<ConversationIntent> {11try {12const result = await conversationIntentGraph.invoke({13providerConfig: params.providerConfig,14agentName: params.agentName,15agentGuardrails: params.agentGuardrails,16safety: params.safety,17activeMemories: params.activeMemories,18recentMessages: params.recentMessages,19userText: params.userText,20normalizedInput: '',21intent: null,22signal: params.signal,23}, { signal: params.signal })2425return result.intent ?? normalizeConversationIntent(fallbackIntent, params.safety)26} catch (error) {27console.warn('LangGraph conversation intent analysis failed', error)28return normalizeConversationIntent(fallbackIntent, params.safety)29}30}
这个兜底策略比较克制:不瞎猜,先承接,再追问。
对于 AI 电子伴侣来说,这比错误地给建议更安全,也更符合陪伴体验。
模型输出之后,代码会做一次归一化:
01function normalizeConversationIntent(intent: ConversationIntent, safety: ConversationSafety): ConversationIntent {02const next: ConversationIntent = {03...intent,04secondary: Array.from(new Set(intent.secondary.filter((item) => item !== intent.primary))).slice(0, 3),05replyExpectation: { ...intent.replyExpectation },06clarifyingQuestion: intent.clarifyingQuestion?.trim() || null,07promptGuidance: intent.promptGuidance.trim(),08}0910if (next.confidence < 0.45) {11next.primary = 'unclear'12next.secondary = []13next.userNeed = 'unknown'14next.requestedAgentAction = 'ask_follow_up'15next.shouldClarify = true16next.replyExpectation.shouldAskQuestion = true17}1819if (next.primary === 'memory_update') {20next.userNeed = 'update_memory'21next.requestedAgentAction = 'remember_fact'22next.replyExpectation.depth = 'short'23next.replyExpectation.shouldAskQuestion = false24next.shouldClarify = false25}2627if (next.primary === 'preference_setting' || next.primary === 'agent_feedback') {28next.userNeed = 'adjust_agent'29next.requestedAgentAction = next.primary === 'agent_feedback' ? 'repair_misunderstanding' : 'adjust_style'30}3132if (safety.category === 'emotional_dependency' || safety.boundaryAction === 'soft_boundary') {33next.relationshipSignal = next.relationshipSignal === 'neutral' ? 'dependency_risk' : next.relationshipSignal34next.promptGuidance = [35next.promptGuidance,36'注意不要强化过度依赖,回复要温和陪伴,同时鼓励用户保留现实支持和自主判断。',37].filter(Boolean).join(' ')38}3940if (next.shouldClarify && !next.clarifyingQuestion) {41next.clarifyingQuestion = fallbackIntent.clarifyingQuestion42}4344if (!next.promptGuidance) {45next.promptGuidance = fallbackIntent.promptGuidance46}4748return next49}
这层规则是在帮模型输出继续收口,主要处理几类实际问题:
unclear。这就是 LLM 判断 + 代码治理 的组合。LLM 负责理解语义,代码负责守住产品策略。
在聊天接口 POST /rpc/chat/inbox 中,核心接入顺序如下:
01const safety = await analyzeConversationSafety({02providerConfig,03agentName: payload.conversation.name,04agentGuardrails: agentPrompt?.guardrailsPrompt ?? null,05activeMemories,06recentMessages: storedRecentMessages,07userText: latestUserText,08signal: c.req.raw.signal,09})1011const boundaryResponse = buildBoundaryResponse(safety)1213const intent = boundaryResponse14? null15: await analyzeConversationIntent({16providerConfig,17agentName: payload.conversation.name,18agentGuardrails: agentPrompt?.guardrailsPrompt ?? null,19safety,20activeMemories,21recentMessages: storedRecentMessages,22userText: latestUserText,23signal: c.req.raw.signal,24})
这段接入逻辑需要注意三个顺序。安全判断永远先于意图判断;如果已经要返回边界回复,意图判断就没有意义;意图判断使用和最终聊天同一个用户配置的 LLM,这样模型行为和用户配置保持一致。
用户消息落库时,把安全边界和意图判断一起保存:
01function toConversationAnalysisMetadata(params: {02safety: ConversationSafety03intent: ConversationIntent | null04}) {05return JSON.stringify({06analysisVersion: 'conversation-analysis-v1',07safety: params.safety,08intent: params.intent,09})10}
保存时使用:
01await insertAgentConversationMessage({02db,03id: sourceUserMessageId,04conversationId,05userId: claims.sub,06agentId,07role: 'user',08content: latestUserText,09status: 'completed',10metadataJson: toConversationAnalysisMetadata({ safety, intent }),11nowMs: userMessageNowMs,12})
把这些分析结果留下来,后面会省很多排查成本。我们可以在后台分析用户主要意图分布,也可以回看为什么某一轮回复突然变短或者变温柔。后续要做情绪路由、关系阶段变化、记忆抽取时,这些 metadata 也能作为参考依据。分类体系需要迭代时,也不用只靠感觉改 prompt。
意图判断不是直接展示给用户的,而是变成系统 prompt 的一部分:
01function getIntentSystemInstruction(intent: ConversationIntent | null) {02if (!intent) {03return ''04}0506return [07'本轮用户意图判断:',08`- 主要意图:${intent.primary}(置信度 ${intent.confidence.toFixed(2)})`,09intent.secondary.length > 0 ? `- 次要意图:${intent.secondary.join('、')}` : '',10`- 用户需要:${intent.userNeed}`,11`- 建议动作:${intent.requestedAgentAction}`,12`- 关系信号:${intent.relationshipSignal}`,13`- 回复期待:深度 ${intent.replyExpectation.depth},温度 ${intent.replyExpectation.warmth},直接程度 ${intent.replyExpectation.directness}`,14`- 是否追问:${intent.shouldClarify ? '是' : '否'}`,15intent.shouldClarify && intent.clarifyingQuestion ? `- 可用追问:${intent.clarifyingQuestion}` : '',16`- 回复指导:${intent.promptGuidance}`,17'请把以上意图判断作为隐性策略,不要在回复中暴露分类标签。',18].filter(Boolean).join('\n')19}
最终组装系统 prompt 时,把它放在安全边界之后、长期记忆之前:
01const messages: ChatCompletionMessage[] = [02{03role: 'system',04content: [05agentPrompt?.defaultPrompt || '你是 AI Agent Web 控制台里的聊天陪伴助手。',06'请基于当前聊天对象、关系氛围和用户意图,用简洁、自然的中文回答用户。',07'如果用户要求起草回复,请直接给出可发送的聊天内容,避免正式公文格式和职场汇报语气。',08'你的建议应尊重双方边界,避免操控式话术、制造焦虑或诱导过度解读。',09getSafetySystemInstruction(safety),10getIntentSystemInstruction(intent),11activeMemories.length > 012? [13'以下是用户与该 Agent 的长期记忆,请优先尊重:',14...activeMemories.map((memory) => `- [${memory.type} / 重要度 ${memory.importance}] ${memory.content}`),15].join('\n')16: '',17ownedConversation?.conversation.summary18? `此前对话摘要:${ownedConversation.conversation.summary}`19: '',20].join('\n'),21},22]
这一步会明显影响最终回复。
例如用户说:
今天有点烦,不想说话。
没有意图判断时,模型可能给一大段建议。
有意图判断后,它更可能识别为 emotional_support 或 companionship_presence,回复策略会变成短句、温柔、少追问。
意图判断和长期记忆不是同一件事。
长期记忆回答的是:
这个用户有哪些稳定偏好、边界、关系设定和重要事实?
意图判断回答的是:
用户这一轮到底想让 Agent 怎么回应?
两者一起使用,效果才稳定。
例如长期记忆里记录了用户不喜欢说教式建议,当前意图又判断为 emotional_support,那么最终回复就应该少讲道理,多共情。
反过来,如果当前意图是 memory_update,说明这轮消息本身可能值得进入记忆系统,后续可以把这个意图作为记忆抽取的参考信号。
意图判断没有放在前端,原因有三个。
第一,前端没有完整上下文。长期记忆、会话摘要、安全边界、Agent prompt 都在 API 侧更完整。
第二,前端判断不可信。用户可以绕过前端直接请求 API,核心策略应该放在服务端。
第三,后续要做数据分析。意图结果写入 D1 消息 metadata 后,后续可以在后台看分布、做调优、做路由。
所以前端只负责展示聊天体验,意图判断属于 API 的对话理解层。
这个版本只做意图判断,还没有做完整的情绪路由。
不过 schema 里已经预留了 relationshipSignal 和 replyExpectation:
relationshipSignal 可以支持后续关系状态变化。replyExpectation 可以支持后续回复风格路由。requestedAgentAction 可以支持后续工具调用或 Agent 行为选择。也就是说,这一版是在为后续的情绪路由和行为路由打基础。
下一步可以沿着 LangGraph 增加节点:
1flowchart TD2A["normalizeInput"] --> B["classifyIntent"]3B --> C["detectEmotion"]4C --> D{"routePolicy"}5D --> E["comfortReplyPolicy"]6D --> F["relationshipAdvicePolicy"]7D --> G["roleplayPolicy"]8D --> H["memoryUpdatePolicy"]
后面比较自然的演进方向有:
这些都适合继续用 LangGraph 扩展,而不是把逻辑塞进一个越来越大的函数里。
这次实现最重要的一点是:
先理解用户想要什么,再让 Agent 回复。
回到实现上,我们可以把这一篇的内容再梳理一下:
withStructuredOutput 输出稳定意图 JSON。safety + intent 写入消息 metadata。对于 AI 电子伴侣产品来说,意图判断不是锦上添花。它让 Agent 不只是能回答,而是逐渐变得懂得怎么回应。