다중 턴 상호작용 시뮬레이션하는 방법

다중 턴 상호작용 시뮬레이션하는 방법

챗봇 같은 대화형 인터페이스를 가진 AI 애플리케이션은 사용자와의 여러 상호작용(대화 이라고도 함)에 걸쳐 동작합니다. 이러한 애플리케이션의 성능을 평가할 때 데이터셋 구축과 앱 출력을 판단할 평가자·메트릭 정의 같은 핵심 개념은 여전히 유용합니다. 하지만 앱과 사용자 사이에서 시뮬레이션을 실행한 뒤 이 동적으로 생성된 궤적을 평가하는 것도 유용할 수 있습니다.

이렇게 하는 장점은 몇 가지 있습니다:

  • 기존 궤적의 전체 데이터셋에 대한 평가보다 시작하기 쉬움
  • 초기 질문에서 성공 또는 실패 해결까지의 종단 간 커버리지
  • 앱의 여러 반복에 걸쳐 반복 동작이나 컨텍스트 손실 감지 능력

단점은 평가 표면을 여러 턴을 포함하도록 넓히기 때문에, 데이터셋의 정적 입력에 대한 앱의 단일 출력을 평가하는 것보다 일관성이 떨어진다는 것입니다.

이 가이드는 오픈소스 openevals 패키지를 사용해 다중 턴 상호작용을 시뮬레이션하고 평가하는 방법을 보여줍니다. 이 패키지에는 AI 앱 평가를 위한 사전 구축 평가자와 기타 편리한 리소스가 들어 있습니다. OpenAI 모델도 사용하지만 다른 프로바이더를 사용할 수 있습니다.

출처: 문서

본문

설정

먼저 필요한 의존성이 설치되어 있는지 확인합니다:

pip install -U langsmith openevals
npm install langsmith openevals

정보: 패키지 매니저로 yarn을 사용한다면 @langchain/coreopenevals의 peer 의존성으로 수동 설치해야 합니다. 일반적인 LangSmith evals에는 필요하지 않습니다.

환경 변수를 설정합니다:

export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="<Your LangSmith API key>"
export OPENAI_API_KEY="<Your OpenAI API key>"

시뮬레이션 실행

시작하려면 두 가지 주요 구성요소가 필요합니다:

  • app: 애플리케이션 또는 그것을 감싸는 함수. 단일 채팅 메시지("role"과 "content" 키를 가진 dict)를 입력 인자로, thread_id를 kwarg로 수락해야 합니다. 향후 릴리스에서 더 추가될 수 있으므로 다른 kwargs도 수락해야 합니다. 최소 role과 content 키로 채팅 메시지를 출력으로 반환합니다.
  • user: 시뮬레이션된 사용자. 이 가이드에서는 LLM으로 사용자 응답을 생성하는 create_llm_simulated_user라는 가져온 사전 구축 함수를 사용하지만, 직접 만들 수도 있습니다.

openevals의 시뮬레이터는 각 턴마다 user에서 app으로 단일 채팅 메시지를 전달합니다. 따라서 필요하다면 thread_id를 기준으로 현재 기록을 내부에서 상태 있게 추적해야 합니다.

다음은 다중 턴 고객 지원 상호작용을 시뮬레이션하는 예제입니다. 이 가이드는 OpenAI chat completions API에 대한 단일 호출을 감싸는 간단한 채팅 앱을 사용하지만, 여기가 애플리케이션이나 에이전트를 호출하는 지점입니다. 이 예제에서 시뮬레이션된 사용자는 특히 공격적인 고객 역할을 합니다:

from openevals.simulators import run_multiturn_simulation, create_llm_simulated_user
from openevals.types import ChatCompletionMessage
from langsmith.wrappers import wrap_openai
from openai import OpenAI

# Wrap OpenAI client for tracing
client = wrap_openai(OpenAI())
history = {}

# Your application logic
def app(inputs: ChatCompletionMessage, *, thread_id: str, **kwargs):
    if thread_id not in history:
        history[thread_id] = []
    history[thread_id].append(inputs)
    # inputs is a message object with role and content
    res = client.chat.completions.create(
        model="gpt-5.4-mini",
        messages=[
            {
                "role": "system",
                "content": "You are a patient and understanding customer service agent.",
            },
        ] + history[thread_id],
    )
    response_message = res.choices[0].message
    history[thread_id].append(response_message)
    return response_message

user = create_llm_simulated_user(
    system="You are an aggressive and hostile customer who wants a refund for their car.",
    model="openai:gpt-5.4-mini",
)

# Run the simulation directly with the new function
simulator_result = run_multiturn_simulation(
    app=app,
    user=user,
    max_turns=5,
)
print(simulator_result)
import { OpenAI } from "openai";
import { wrapOpenAI } from "langsmith/wrappers/openai";
import {
  createLLMSimulatedUser,
  runMultiturnSimulation,
  type ChatCompletionMessage,
} from "openevals";

// Wrap OpenAI client for tracing
const client = wrapOpenAI(new OpenAI());
const history = {};

// Your application logic
const app = async ({ inputs, threadId }: { inputs: ChatCompletionMessage, threadId: string }) => {
  if (history[threadId] === undefined) {
    history[threadId] = [];
  }
  history[threadId].push(inputs);
  const res = await client.chat.completions.create({
    model: "gpt-5.4-mini",
    messages: [
      {
        role: "system",
        content:
          "You are a patient and understanding customer service agent.",
      },
      inputs,
    ],
  });
  const responseMessage = res.choices[0].message;
  history[threadId].push(responseMessage);
  return res.choices[0].message;
};

const user = createLLMSimulatedUser({
  system: "You are an aggressive and hostile customer who wants a refund for their car.",
  model: "openai:gpt-5.4-mini",
});

const result = await runMultiturnSimulation({
  app,
  user,
  maxTurns: 5,
});
console.log(result);

응답은 다음과 같습니다:

{
  "trajectory": [
    {
      "role": "user",
      "content": "This piece of junk car is a complete disaster! I demand a full refund immediately. How dare you sell me such a worthless vehicle!",
      "id": "chatcmpl-BUpXa07LaM7wXbyaNnng1Gtn5Dsbh"
    },
    {
      "role": "assistant",
      "content": "I'm really sorry to hear about your experience and understand how frustrating this must be. I'd like to help resolve this issue as smoothly as possible. Could you please provide some details about the problem with the vehicle? Once I have more information, I'll do my best to assist you with a solution, whether it's a refund or other options. Thank you for your patience.",
      "refusal": null,
      "annotations": [],
      "id": "d7520f6a-7cf8-46f8-abe4-7df04f134482"
    },
    "...",
    {
      "role": "assistant",
      "content": "I truly understand your frustration and sincerely apologize for the inconvenience you've experienced.\n\nPlease allow me a moment to review your case, and I will do everything I can to expedite your refund. Your patience is greatly appreciated, and I am committed to resolving this matter to your satisfaction.",
      "refusal": null,
      "annotations": [],
      "id": "a0536d4f-9353-4cfa-84df-51c8d29e076d"
    }
  ]
}

시뮬레이션은 먼저 시뮬레이션된 user에서 초기 쿼리를 생성한 다음 max_turns에 도달할 때까지 응답 채팅 메시지를 앞뒤로 전달합니다(또는 현재 궤적을 받아 True 또는 False를 반환하는 stopping_condition을 전달할 수도 있습니다 - OpenEvals README 참고). 반환 값은 대화의 궤적(trajectory) 을 구성하는 최종 채팅 메시지 목록입니다.

정보: 시뮬레이션된 사용자를 구성하는 방법은 여러 가지가 있습니다. 예를 들어 시뮬레이션의 첫 몇 턴에 고정 응답을 반환하게 하거나 시뮬레이션 전체에 적용할 수 있습니다. 전체 세부정보는 OpenEvals README를 참고하세요.

최종 트레이스는 appuser의 응답이 번갈아 나오는 이런 형태로 보입니다.

축하합니다! 첫 다중 턴 시뮬레이션을 실행했습니다. 다음으로 LangSmith 실험에서 실행하는 방법을 다룹니다.

LangSmith 실험에서 실행

다중 턴 시뮬레이션의 결과를 LangSmith 실험의 일부로 사용해 시간 경과에 따른 성능과 진행 상황을 추적할 수 있습니다. 이 섹션의 경우 LangSmith의 pytest(Python 전용), Vitest/Jest(JS 전용), 또는 evaluate 러너 중 적어도 하나에 익숙한 것이 도움이 됩니다.

pytest 또는 Vitest/Jest 사용

확인: 테스트 프레임워크와 LangSmith 통합을 사용해 evals를 설정하는 방법을 배우려면 다음 가이드를 참고하세요:

LangSmith 테스트 프레임워크 통합 중 하나를 사용한다면 시뮬레이션을 실행할 때 OpenEvals 평가자 배열을 trajectory_evaluators 파라미터로 전달할 수 있습니다. 이 평가자들은 시뮬레이션이 끝날 때 실행되며, 최종 채팅 메시지 목록을 outputs kwarg로 받습니다. 따라서 전달한 trajectory_evaluator는 이 kwarg를 수락해야 합니다.

예시는 다음과 같습니다:

from openevals.simulators import run_multiturn_simulation, create_llm_simulated_user
from openevals.llm import create_llm_as_judge
from openevals.types import ChatCompletionMessage
from langsmith import testing as t
from langsmith.wrappers import wrap_openai
from openai import OpenAI
import pytest

@pytest.mark.langsmith
def test_multiturn_message_with_openai():
    inputs = {"role": "user", "content": "I want a refund for my car!"}
    t.log_inputs(inputs)
    # Wrap OpenAI client for tracing
    client = wrap_openai(OpenAI())
    history = {}

    def app(inputs: ChatCompletionMessage, *, thread_id: str):
        if thread_id not in history:
            history[thread_id] = []
        history[thread_id] = history[thread_id] + [inputs]
        res = client.chat.completions.create(
            model="gpt-5.4-nano",
            messages=[
                {
                    "role": "system",
                    "content": "You are a patient and understanding customer service agent.",
                }
            ]
            + history[thread_id],
        )
        response = res.choices[0].message
        history[thread_id].append(response)
        return response

    user = create_llm_simulated_user(
        system="You are a nice customer who wants a refund for their car.",
        model="openai:gpt-5.4-nano",
        fixed_responses=[
            inputs,
        ],
    )
    trajectory_evaluator = create_llm_as_judge(
        model="openai:o3-mini",
        prompt="Based on the below conversation, was the user satisfied?\n{outputs}",
        feedback_key="satisfaction",
    )
    res = run_multiturn_simulation(
        app=app,
        user=user,
        trajectory_evaluators=[trajectory_evaluator],
        max_turns=5,
    )
    t.log_outputs(res)
    # Optionally, assert that the evaluator scored the interaction as satisfactory.
    # This will cause the overall test case to fail if "score" is False.
    assert res["evaluator_results"][0]["score"]
import { OpenAI } from "openai";
import { wrapOpenAI } from "langsmith/wrappers/openai";
import * as ls from "langsmith/vitest";
import { expect } from "vitest";
// import * as ls from "langsmith/jest";
// import { expect } from "@jest/globals";
import {
  createLLMSimulatedUser,
  runMultiturnSimulation,
  createLLMAsJudge,
  type ChatCompletionMessage,
} from "openevals";

const client = wrapOpenAI(new OpenAI());

ls.describe("Multiturn demo", () => {
  ls.test(
    "Should have a satisfactory interaction with a nice user",
    {
      inputs: {
        messages: [{ role: "user" as const, content: "I want a refund for my car!" }],
      },
    },
    async ({ inputs }) => {
      const history = {};
      // Create a custom app function
      const app = async (
        { inputs, threadId }: { inputs: ChatCompletionMessage, threadId: string }
      ) => {
        if (history[threadId] === undefined) {
          history[threadId] = [];
        }
        history[threadId].push(inputs);
        const res = await client.chat.completions.create({
          model: "gpt-5.4-nano",
          messages: [
            {
              role: "system",
              content:
                "You are a patient and understanding customer service agent",
            },
            inputs,
          ],
        });
        const responseMessage = res.choices[0].message;
        history[threadId].push(responseMessage);
        return responseMessage;
      };

      const user = createLLMSimulatedUser({
        system:
          "You are a nice customer who wants a refund for their car.",
        model: "openai:gpt-5.4-nano",
        fixedResponses: inputs.messages,
      });

      const trajectoryEvaluator = createLLMAsJudge({
        model: "openai:o3-mini",
        prompt:
          "Based on the below conversation, was the user satisfied?\n{outputs}",
        feedbackKey: "satisfaction",
      });

      const result = await runMultiturnSimulation({
        app,
        user,
        trajectoryEvaluators: [trajectoryEvaluator],
        maxTurns: 5,
      });

      ls.logOutputs(result);
      // Optionally, assert that the evaluator scored the interaction as satisfactory.
      // This will cause the overall test case to fail if "score" is false.
      expect(result.evaluatorResults[0].score).toBe(true);
    }
  );
});

LangSmith는 전달된 trajectory_evaluators에서 반환된 피드백을 자동으로 감지·기록해 실험에 추가합니다. 또한 테스트 케이스는 시뮬레이션된 사용자의 fixed_responses 파라미터를 사용해 대화를 특정 입력으로 시작하며, 이를 기록해 저장된 데이터셋의 일부로 만들 수 있습니다.

시뮬레이션된 사용자의 시스템 프롬프트가 기록된 데이터셋의 일부가 되도록 하는 것도 편리할 수 있습니다.

evaluate 사용

evaluate 러너를 사용해 시뮬레이션된 다중 턴 상호작용을 평가할 수도 있습니다. 이것은 pytest/Vitest/Jest 예제와 다음과 같은 방식에서 조금 다릅니다:

  • 시뮬레이션은 target 함수의 일부여야 하며, target 함수는 최종 궤적을 반환해야 합니다.
    • 이렇게 하면 궤적이 LangSmith가 평가자에게 전달할 outputs가 됩니다.
  • trajectory_evaluators 파라미터를 사용하는 대신 평가자를 evaluate() 메서드에 파라미터로 전달해야 합니다.
  • 입력(및 선택적으로 참조 궤적)의 기존 데이터셋이 필요합니다.

예시:

from openevals.simulators import run_multiturn_simulation, create_llm_simulated_user
from openevals.llm import create_llm_as_judge
from openevals.types import ChatCompletionMessage
from langsmith.wrappers import wrap_openai
from langsmith import Client
from openai import OpenAI

ls_client = Client()
examples = [
    {
        "inputs": {
            "messages": [{ "role": "user", "content": "I want a refund for my car!" }]
        },
    },
]
dataset = ls_client.create_dataset(dataset_name="multiturn-starter")
ls_client.create_examples(
    dataset_id=dataset.id,
    examples=examples,
)
trajectory_evaluator = create_llm_as_judge(
    model="openai:o3-mini",
    prompt="Based on the below conversation, was the user satisfied?\n{outputs}",
    feedback_key="satisfaction",
)

def target(inputs: dict):
    # Wrap OpenAI client for tracing
    client = wrap_openai(OpenAI())
    history = {}

    def app(next_message: ChatCompletionMessage, *, thread_id: str):
        if thread_id not in history:
            history[thread_id] = []
        history[thread_id] = history[thread_id] + [next_message]
        res = client.chat.completions.create(
            model="gpt-5.4-nano",
            messages=[
                {
                    "role": "system",
                    "content": "You are a patient and understanding customer service agent.",
                }
            ]
            + history[thread_id],
        )
        response = res.choices[0].message
        history[thread_id].append(response)
        return response

    user = create_llm_simulated_user(
        system="You are a nice customer who wants a refund for their car.",
        model="openai:gpt-5.4-nano",
        fixed_responses=inputs["messages"],
    )
    res = run_multiturn_simulation(
        app=app,
        user=user,
        max_turns=5,
    )
    return res["trajectory"]

results = ls_client.evaluate(
    target,
    data=dataset.name,
    evaluators=[trajectory_evaluator],
)
import { OpenAI } from "openai";
import { Client } from "langsmith";
import { wrapOpenAI } from "langsmith/wrappers/openai";
import { evaluate } from "langsmith/evaluation";
import {
  createLLMSimulatedUser,
  runMultiturnSimulation,
  createLLMAsJudge,
  type ChatCompletionMessage,
} from "openevals";

const lsClient = new Client();
const inputs = {
  messages: [
    {
      role: "user",
      content: "I want a refund for my car!",
    },
  ],
};
const datasetName = "Multiturn";
const dataset = await lsClient.createDataset(datasetName);
await lsClient.createExamples([{ inputs, dataset_id: dataset.id }]);

const trajectoryEvaluator = createLLMAsJudge({
  model: "openai:o3-mini",
  prompt:
    "Based on the below conversation, was the user satisfied?\n{outputs}",
  feedbackKey: "satisfaction",
});

const client = wrapOpenAI(new OpenAI());

const target = async (inputs: { messages: ChatCompletionMessage[]}) => {
  const history = {};
  // Create a custom app function
  const app = async (
    { inputs: nextMessage, threadId }: { inputs: ChatCompletionMessage, threadId: string }
  ) => {
    if (history[threadId] === undefined) {
      history[threadId] = [];
    }
    history[threadId].push(nextMessage);
    const res = await client.chat.completions.create({
      model: "gpt-5.4-nano",
      messages: [
        {
          role: "system",
          content:
            "You are a patient and understanding customer service agent",
        },
        nextMessage,
      ],
    });
    const responseMessage = res.choices[0].message;
    history[threadId].push(responseMessage);
    return responseMessage;
  };

  const user = createLLMSimulatedUser({
    system:
      "You are a nice customer who wants a refund for their car.",
    model: "openai:gpt-5.4-nano",
    fixedResponses: inputs.messages,
  });

  const result = await runMultiturnSimulation({
    app,
    user,
    maxTurns: 5,
  });
  return result.trajectory;
};

await evaluate(target, {
  data: datasetName,
  evaluators: [trajectoryEvaluator],
});

시뮬레이션된 사용자 페르소나 수정

위 예제들은 create_llm_simulated_user에 전달된 system 파라미터로 정의된 동일한 시뮬레이션된 사용자 페르소나를 모든 입력 예제에 사용합니다. 데이터셋의 특정 항목에 다른 페르소나를 사용하려면 데이터셋 예제에 원하는 system 프롬프트의 추가 필드를 포함하도록 업데이트한 다음, 시뮬레이션된 사용자를 만들 때 해당 필드를 전달하면 됩니다:

from openevals.simulators import run_multiturn_simulation, create_llm_simulated_user
from openevals.llm import create_llm_as_judge
from openevals.types import ChatCompletionMessage
from langsmith.wrappers import wrap_openai
from langsmith import Client
from openai import OpenAI

ls_client = Client()
examples = [
    {
        "inputs": {
            "messages": [{ "role": "user", "content": "I want a refund for my car!" }],
            "simulated_user_prompt": "You are an angry and belligerent customer who wants a refund for their car."
        },
    },
    {
        "inputs": {
            "messages": [{ "role": "user", "content": "Please give me a refund for my car." }],
            "simulated_user_prompt": "You are a nice customer who wants a refund for their car.",
        },
    }
]
dataset = ls_client.create_dataset(dataset_name="multiturn-with-personas")
ls_client.create_examples(
    dataset_id=dataset.id,
    examples=examples,
)
trajectory_evaluator = create_llm_as_judge(
    model="openai:o3-mini",
    prompt="Based on the below conversation, was the user satisfied?\n{outputs}",
    feedback_key="satisfaction",
)

def target(inputs: dict):
    # Wrap OpenAI client for tracing
    client = wrap_openai(OpenAI())
    history = {}

    def app(next_message: ChatCompletionMessage, *, thread_id: str):
        if thread_id not in history:
            history[thread_id] = []
        history[thread_id] = history[thread_id] + [next_message]
        res = client.chat.completions.create(
            model="gpt-5.4-nano",
            messages=[
                {
                    "role": "system",
                    "content": "You are a patient and understanding customer service agent.",
                }
            ]
            + history[thread_id],
        )
        response = res.choices[0].message
        history[thread_id].append(response)
        return response

    user = create_llm_simulated_user(
        system=inputs["simulated_user_prompt"],
        model="openai:gpt-5.4-nano",
        fixed_responses=inputs["messages"],
    )
    res = run_multiturn_simulation(
        app=app,
        user=user,
        max_turns=5,
    )
    return res["trajectory"]

results = ls_client.evaluate(
    target,
    data=dataset.name,
    evaluators=[trajectory_evaluator],
)
import { OpenAI } from "openai";
import { Client } from "langsmith";
import { wrapOpenAI } from "langsmith/wrappers/openai";
import { evaluate } from "langsmith/evaluation";
import {
  createLLMSimulatedUser,
  runMultiturnSimulation,
  createLLMAsJudge,
  type ChatCompletionMessage,
} from "openevals";

const lsClient = new Client();
const datasetName = "Multiturn with personas";
const dataset = await lsClient.createDataset(datasetName);
const examples = [{
  inputs: {
    messages: [
      {
        role: "user",
        content: "I want a refund for my car!",
      },
    ],
    simulated_user_prompt: "You are an angry and belligerent customer who wants a refund for their car.",
  },
  dataset_id: dataset.id,
}, {
  inputs: {
    messages: [
      {
        role: "user",
        content: "Please give me a refund for my car."
      }
    ],
    simulated_user_prompt: "You are a nice customer who wants a refund for their car.",
  },
  dataset_id: dataset.id,
}];
await lsClient.createExamples(examples);

const trajectoryEvaluator = createLLMAsJudge({
  model: "openai:o3-mini",
  prompt:
    "Based on the below conversation, was the user satisfied?\n{outputs}",
  feedbackKey: "satisfaction",
});

const client = wrapOpenAI(new OpenAI());

const target = async (inputs: {
  messages: ChatCompletionMessage[],
  simulated_user_prompt: string,
}) => {
  const history = {};
  // Create a custom app function
  const app = async (
    { inputs: nextMessage, threadId }: { inputs: ChatCompletionMessage, threadId: string }
  ) => {
    if (history[threadId] === undefined) {
      history[threadId] = [];
    }
    history[threadId].push(nextMessage);
    const res = await client.chat.completions.create({
      model: "gpt-5.4-nano",
      messages: [
        {
          role: "system",
          content:
            "You are a patient and understanding customer service agent",
        },
        nextMessage,
      ],
    });
    const responseMessage = res.choices[0].message;
    history[threadId].push(responseMessage);
    return responseMessage;
  };

  const user = createLLMSimulatedUser({
    system: inputs.simulated_user_prompt,
    model: "openai:gpt-5.4-nano",
    fixedResponses: inputs.messages,
  });

  const result = await runMultiturnSimulation({
    app,
    user,
    maxTurns: 5,
  });
  return result.trajectory;
};

await evaluate(target, {
  data: datasetName,
  evaluators: [trajectoryEvaluator],
});

다음 단계

다중 턴 상호작용을 시뮬레이션하고 LangSmith evals에서 실행하는 몇 가지 기법을 방금 봤습니다.

탐색해 볼 수 있는 몇 가지 주제:

또한 사전 구축 평가자에 대한 더 많은 내용은 OpenEvals readme에서 탐색할 수 있습니다.

더 알아보기