iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
AI & Data

30 天從 0 至 1 建立一個自已的 AI 學習工具人系列 第 12

30-12: [實作-5] 讓我們的 AI 工具人可以準確記住回答過的學生背景

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250927/20089358ekZcoh1GFZ.png

在上一篇文章中,我們碰到一個問題。

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 版本 )

然後整個結構用下面這張圖來回憶一下。

https://ithelp.ithome.com.tw/upload/images/20250926/20089358HwgB8EJuvK.png

然後這裡順到回憶一下,為什麼會拆成 Workflow 與 Agent 的記憶是分開來的 :

主要是原因在於用途設計出來就不同

Workflow 是個流程的狀態,它本來設計是用來做一些例如人為中斷操作下判斷的功用,而 Agent 用的比較算是聊天的上下文記錄,但真的要做的話,也不是不能混在一起記錄,但我自已會覺得分開來比較好管理與維護。

🤔 那為什麼他還是會失憶呢?

真實原因我也不知道,我在猜測應該是因為我們那時後的回答是會需要根據整個上下文進行推理後,才能判斷是否有回答過,在猜有可能是上下文太多了,反而讓他很難判斷。

所以這時我想到個改善法。

實作可以根據用戶的回答,來建立狀態的機制。

🚀 然後接下來我們的方案

整個流程如下圖 :

  1. Init : 用戶一開始進來就會先取得到狀態 ( 會取 Redis 取得 )
  2. RouteAI : 會判斷意圖,之前會直接走到 Learning,但在進來它之前,現在多加了 BackgroundAI。
  3. BackgroundAI : 會先決定有沒有問題,才來決定要不要執行 LLM。
  4. LearningAI : 然後這個階段就不會在問了,因為它已經從 BackgroundAI 的 state 拿到學生的背景資料了。

https://ithelp.ithome.com.tw/upload/images/20250926/20089358eoXyJUpqvS.png

然後其中在第 4 步,當 LearningAI 那裡有了學生背景後,他就是會將插入到 system prompt 中,大約如下的程式碼,這裡就只重點部份,然後要點在,這 3 個就是用學生的背景知識來加工我們的 prompt :

  • 精通 ${studentBackground.domain} 領域
  • 學生對這個領域的熟悉程度是 ${studentBackground.domain}: ${studentBackground.level}
  • 可否跳過此步驟,直接進入 Step 1: ${hasAskBackground} ,true 表示可跳過,false 表示不可跳過

  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 就是專門做兩件事情 :

  1. 尋問學生的背景
  2. 等到學生回答後,會回傳個類似 { domain: '日本戰國', level: 'low' } 放在 state 中,然後再接下來的 learningAI 就可以使用這個資訊了了。

然後這裡還有個重點,我是用 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;
  }

🚀 小總結

今天我們這篇文章主要學到了幾個事情 :

  • 我們讓整個 AI 引導者在問我們的背景時,不會一直重複的鬼打牆了。
  • 我們學到根據用 state 來加入我們的 system prompt,讓我們的 prompt 可以有更好的上下文 ( 學生背景 )
  • 也複習了一些記憶功能相關的東西。

不過今天的實作事實上還有很多要做,現在做的版本還是有點粗糙,很多的細節也都只是快速的想過而以,包含記憶的保存時間,或是有沒有可能有多背景的可能,不就鐵人賽邊寫文章,邊寫程式碼的情況下,還真難做到完美…… 我加油。

好好的星期五晚上我竟然一直做在電腦前…


上一篇
30-11: [實作-4] 讓我們的 AI 工具人來幫我們總結今日的學習
下一篇
30-13: [知識] 讓我們 AI 工具人呼叫工具之 Functional Calling
系列文
30 天從 0 至 1 建立一個自已的 AI 學習工具人14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言