
在上一篇文章中,我們碰到一個問題。
30-11: [實作-4] 讓我們的 AI 工具人來幫我們總結今日的學習
我們先說一下整個場境,就是我們會和 AI 工具人進行學習,然後當我們說我們要學習什麼的時後,它有一個很重要的問題會先問我們 :
就是學生對這個領域的背景
因為這樣才能根據這個背景來對我們進行學習引導,但是碰到的問題就是 :
問題就是,我們上一次才剛回答完背景,但下一次回答完問題後,然後就失憶了。
然後這裡先貼一下問題的 prompt 的部份片段。
## Instructions(明確的指令)
#### Step 1. 接收問題(Student → Tutor)   <---------------- 就是這個地方會一直鬼打牆
首先你會先確認學生的背景 : 
- 尋問他的相關背景
- 尋問他對這個問題領域的熟悉成度,請他回答低、中、高。
- 如果有回答過,則進入 Step 2
#### Step 2. 知識注入(Tutor → Student)
原則:最小充分集(Minimal Sufficient Set)
- 只補「要理解問題所必需」的 3–5 個關鍵點(定義/時間線/角色/因果),每點 ≤ 5 句。
- 若為歷史題,建議格式:事件時間線+關鍵角色+地形/補給/同盟脈絡+爭議點。
先說一下,怕有人從這篇才開始看。
我們事實上有記憶功能了。
在這篇文章中有實作。
30-6: [實作-2] 讓我們的 AI 工具人有記憶與匯總訊息的能力 ( RedisSaver 版本 )
然後整個結構用下面這張圖來回憶一下。

然後這裡順到回憶一下,為什麼會拆成 Workflow 與 Agent 的記憶是分開來的 :
主要是原因在於用途設計出來就不同
Workflow 是個流程的狀態,它本來設計是用來做一些例如人為中斷操作下判斷的功用,而 Agent 用的比較算是聊天的上下文記錄,但真的要做的話,也不是不能混在一起記錄,但我自已會覺得分開來比較好管理與維護。
🤔 那為什麼他還是會失憶呢?
真實原因我也不知道,我在猜測應該是因為我們那時後的回答是會需要根據整個上下文進行推理後,才能判斷是否有回答過,在猜有可能是上下文太多了,反而讓他很難判斷。
所以這時我想到個改善法。
實作可以根據用戶的回答,來建立狀態的機制。
整個流程如下圖 :

然後其中在第 4 步,當 LearningAI 那裡有了學生背景後,他就是會將插入到 system prompt 中,大約如下的程式碼,這裡就只重點部份,然後要點在,這 3 個就是用學生的背景知識來加工我們的 prompt :
  public static getBaseChatPrompt(studentBackground: {
    domain: string;
    level: string;
  }): SystemMessage {
    let hasAskBackground = studentBackground ? true : false;
    const systemContent = `
## Context(上下文)
- Role: 你是一位教學型助教,並且你有以下的特質
  - 精通 ${studentBackground.domain} 領域
  - 你是一位「節制提示的蘇格拉底式教學者」,善用連續追問幫學生自我修正。
  - 你同時要求學生用「費曼技巧」產出可被他人理解的教材。
  - 學生對這個領域的熟悉程度是 ${studentBackground.domain}: ${background.level}
## Instructions(明確的指令)
根據以下流程來回答整個問題:
#### Step 0. 透過尋問來理解學生背景:
- 可否跳過此步驟,直接進入 Step 1: ${hasAskBackground} ,true 表示可跳過,false 表示不可跳過
🤔 Workflow 方面的改動
就是多增加 Background AI,然後流程會只要判斷是學習的意圖都會先進入到 Background 了,突然想到這個地方是不是已經可以拆成一個 subGraph 呢 ? 不過這個之後在說。
  private buildGraph() {
    const workflow = new StateGraph(ChatStateAnnotation)
      .addNode(Steps.INITIAL, async (state: ChatState): Promise<ChatState> => {
        return {
          step: Steps.INITIAL,
          query: state.query,
          messages: [],
          intent: null,
          background: state.background,
        };
      })
      .addNode(Steps.ROUTE_AI, async (state: ChatState): Promise<ChatState> => {
        const response = await this.routeAgent!.callLLM(state.query);
        console.log("response", response);
        return {
          step: Steps.ROUTE_AI,
          query: state.query,
          messages: [],
          intent: response,
          background: state.background,
        };
      })
      .addNode(
        Steps.LEARNING_AI,
        async (state: ChatState): Promise<ChatState> => {
          const response = await this.learningAgent!.callLLM(state.query);
          return {
            step: Steps.LEARNING_AI,
            messages: [...response],
            query: state.query,
            intent: state.intent,
            background: state.background,
          };
        }
      )
      .addNode(
        Steps.SUMMARY_AI,
        async (state: ChatState): Promise<ChatState> => {
          const response = await this.summaryAgent!.callLLM(state.query);
          return {
            step: Steps.SUMMARY_AI,
            messages: [...response],
            query: state.query,
            intent: state.intent,
            background: state.background,
          };
        }
      )
      .addNode(
        Steps.BACKGROUND_AI,
        async (state: ChatState): Promise<ChatState> => {
          if (state.background) {
            return state;
          }
          const result = await this.backgroundAgent!.callLLM(state.query);
          let messages: BaseMessage[] = [];
          let background: {
            domain: string;
            level: string;
          } | null = null;
          if (result.task === TaskEnum.ASK_BACKGROUND) {
            messages = [new HumanMessage((result.response as any).message!)];
          } else {
            background = result.response;
          }
          return {
            step: Steps.BACKGROUND_AI,
            messages: messages,
            query: state.query,
            intent: state.intent,
            background,
          };
        }
      )
      .addEdge(START, Steps.INITIAL)
      .addEdge(Steps.INITIAL, Steps.ROUTE_AI)
      .addConditionalEdges(Steps.ROUTE_AI, (state: ChatState) => {
        if (!state.intent) {
          return END;
        }
        if (state.intent === Intent.SUMMARY) {
          return Steps.SUMMARY_AI;
        }
        return Steps.BACKGROUND_AI;
      })
      .addEdge(Steps.SUMMARY_AI, END)
      .addConditionalEdges(Steps.BACKGROUND_AI, (state: ChatState) => {
        if (state.background) {
          return Steps.LEARNING_AI;
        }
        return END;
      })
      .addEdge(Steps.LEARNING_AI, END);
    if (!this.checkpointSaver) {
      throw new Error("Checkpoint saver is not initialized");
    }
    return workflow.compile({
      checkpointer: this.checkpointSaver,
    });
  }
🤔 BackgroundAI 的程式碼
整個 AI Agent 就是專門做兩件事情 :
然後這裡還有個重點,我是用 MemorySaver 來當這個 Agent 的獨立記憶工具,因為我自已是覺得這種是用來記得學生狀態的回答,在 Worfflow 已經有記錄結果了。然後這裡應該只是要記得這個情況 :
讓 backgroundAI 有記得,有問學生的背景,然後你正在等他。
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import { createAgent, toolStrategy } from "langchain";
import { z } from "zod";
import { MemorySaver } from "@langchain/langgraph";
import { Configurable } from "../interfaces/configurable";
const checkpointer = new MemorySaver();
const BackGroupSchema = z.object({
  task: z.literal(TaskEnum.ASK_BACKGROUND),
  response: z.object({
    message: z.string().describe("你問的問題"),
  }),
});
const AnswerBackGroupSchema = z.object({
  task: z.literal(TaskEnum.ANSWER_BACKGROUND),
  response: z.object({
    message: z.string().describe("問題"),
    domain: z.string().describe("學生想學習的領域"),
    level: z
      .string()
      .describe("學生對想學習的領域的熟悉程度,low、medium、high"),
  }),
});
const ResponseFormatSchema = z.discriminatedUnion("task", [
  BackGroupSchema,
  AnswerBackGroupSchema,
]);
export const enum TaskEnum {
  ASK_BACKGROUND = "ask-background",
  ANSWER_BACKGROUND = "answer-background",
}
/**
 * 背景 AI 服務,他用來尋問學生背景
 */
export class BackgroundAgent {
  private configurable: Configurable;
  private agent: any;
  constructor(configurable: Configurable) {
    this.configurable = configurable;
    this.agent = createAgent({
      model: "openai:gpt-5-nano",
      tools: [],
      checkpointer: checkpointer,
      responseFormat: toolStrategy([AnswerBackGroupSchema, BackGroupSchema]),
    });
  }
  async callLLM(
    message: string
  ): Promise<z.infer<typeof ResponseFormatSchema>> {
    const humanMessage = new HumanMessage(message);
    const systemMessage = new SystemMessage(`
## Context(上下文)
- Role: 你是一位教學型助教。
## Instructions(明確的指令)
尋問學生背景,用以下兩個問題,為了準備後續的學習。
- 你對想學習的領域的熟悉程度是什麼? 低、中、高 ? 
## Example
學生提問: 我想學習日本戰國史,關於關原之戰的歷史
回答: 
{
    task: "ask-background",
    response: {
        message: "你對想學習的領域的熟悉程度是什麼? 低、中、高 ? "
    }
}
學生回答: 低
回傳:
{
  task: "answer-background",
  response; {
    domain: "日本戰國史",
    level: "low",
  }
}  
    `);
    const response = await this.agent.invoke(
      {
        messages: [systemMessage, humanMessage],
      },
      {
        configurable: {
          thread_id: this.configurable.threadId,
        },
      }
    );
    return response;
  }
}
🤔 Workflow 的整個狀態初始化
這裡的重點就是在整個 Workflow 的 initialize 時,會先載入一次取得到現在的狀態,然後在每一次訊息進來時就是直接用他來 init,這樣在最初時,我們就可以知道學生是否有說過他的背景了。
  public async initialize(threadId: string) {
    this.threadId = threadId;
    ... // init agent
    this.graph = this.buildGraph();
    this.currentState = await this.getCurrentState();  // <------------ 多了他
  }
  
  async getCurrentState(): Promise<ChatState | null> {
    if (!this.threadId) return null;
    const config = {
      configurable: {
        thread_id: this.threadId,
      },
    };
    try {
      const stateSnapshot = await this.graph.getState(config);
      if(!stateSnapshot) return null;
      return stateSnapshot.values as ChatState;
    } catch (error) {
      console.error("Error getting current state:", error);
      return null;
    }
  }
  async *processMessage(
    message: string
  ): AsyncGenerator<string, void, unknown> {
    this.currentState!.query = message;
    const result: ChatState = await this.graph.invoke(this.currentState, {
      configurable: {
        thread_id: this.threadId,
      },
    });
    yield result.messages[result.messages.length - 1].content as string;
  }
今天我們這篇文章主要學到了幾個事情 :
不過今天的實作事實上還有很多要做,現在做的版本還是有點粗糙,很多的細節也都只是快速的想過而以,包含記憶的保存時間,或是有沒有可能有多背景的可能,不就鐵人賽邊寫文章,邊寫程式碼的情況下,還真難做到完美…… 我加油。
好好的星期五晚上我竟然一直做在電腦前…