휴먼 인 더 루프

휴먼 인 더 루프 (Human-in-the-loop)

민감한 도구 작업에 대한 인간 승인을 구성하는 방법을 배우세요.

일부 도구 작업은 민감해서 실행 전에 인간 승인이 필요할 수 있어요. Deep Agents는 LangGraph의 인터럽트 기능을 통해 휴먼 인 더 루프 워크플로를 지원합니다. interrupt_on 파라미터로 어떤 도구가 승인을 요구하는지 구성할 수 있습니다. interrupt_on이 설정되면 Deep Agents 스택HumanInTheLoopMiddleware가 추가됩니다. 도구가 결과를 반환하기 전에 실행이 취소되거나 인터럽트되면, 같은 스택의 PatchToolCallsMiddleware가 메시지 기록을 자동으로 복구합니다.

출처: 문서

본문

기본 구성 (Basic configuration)

interrupt_on 파라미터는 도구 이름을 인터럽트 구성에 매핑하는 사전을 받습니다. 각 도구는 다음과 같이 구성할 수 있습니다:

  • True: 기본 동작으로 인터럽트 활성화 (approve, edit, reject, respond 허용)
  • False: 이 도구의 인터럽트 비활성화
  • InterruptOnConfig: 커스텀 구성. allowed_decisions를 설정해 검토 옵션을 제어.
import { tool } from "langchain";
import { createDeepAgent } from "deepagents";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod";

const removeFile = tool(
  async ({ path }: { path: string }) => {
    return `Deleted ${path}`;
  },
  {
    name: "remove_file",
    description: "Delete a file from the filesystem.",
    schema: z.object({
      path: z.string(),
    }),
  },
);

const fetchFile = tool(
  async ({ path }: { path: string }) => {
    return `Contents of ${path}`;
  },
  {
    name: "fetch_file",
    description: "Read a file from the filesystem.",
    schema: z.object({
      path: z.string(),
    }),
  },
);

const notifyEmail = tool(
  async ({
    to,
    subject,
    body,
  }: {
    to: string;
    subject: string;
    body: string;
  }) => {
    return `Sent email to ${to}`;
  },
  {
    name: "notify_email",
    description: "Send an email.",
    schema: z.object({
      to: z.string(),
      subject: z.string(),
      body: z.string(),
    }),
  },
);

// Checkpointer is REQUIRED for human-in-the-loop
const checkpointer = new MemorySaver();

const agent = createDeepAgent({
  model: "google_genai:gemini-3.6-flash",
  tools: [removeFile, fetchFile, notifyEmail],
  interruptOn: {
    remove_file: true, // Default: approve, edit, reject, respond
    fetch_file: false, // No interrupts needed
    notify_email: { allowedDecisions: ["approve", "reject"] }, // No editing
  },
  checkpointer, // Required!
});

결정 유형 (Decision types)

allowed_decisions 목록은 인간이 도구 호출을 검토할 때 취할 수 있는 동작을 제어합니다:

결정 유형 설명 예시 사용 사례
approve 에이전트가 제안한 원래 인자 그대로 도구를 실행. 이메일 초안을 작성한 그대로 보내기
✏️ edit 실행 전에 도구 인자 수정. 이메일을 보내기 전에 수신자 변경
reject 이 도구 호출 실행을 완전히 건너뛰고 거부 피드백을 에이전트에 반환. 파일 삭제를 거부하고 이유 설명
💬 respond 실행을 건너뛰고 인간의 메시지를 합성 도구 결과로 직접 반환. "ask user" 스타일 도구용. "ask_user" 프롬프트에 직접 답변

인간이 제안된 동작을 거부할 때 reject를 사용하세요. 인간이 도구 역할을 할 때(예: ask_user 프롬프트에 답할 때)만 respond를 사용하세요. 부작용이 있는 도구를 거부할 때 respond를 사용하지 마세요. 그 메시지가 모델에 의해 성공적인 도구 결과로 처리될 수 있기 때문입니다.

도구 인자를 **편집**할 때는 보수적으로 변경하세요. 원래 인자에 대한 중대한 수정은 모델이 접근 방식을 재평가하고 도구를 여러 번 실행하거나 예상치 못한 동작을 취하게 할 수 있습니다.

각 도구에 사용 가능한 결정을 커스터마이즈할 수 있습니다:

const interruptOn = {
  // Sensitive operations: allow all options
  delete_file: { allowedDecisions: ["approve", "edit", "reject"] },

  // Moderate risk: approval or rejection only
  write_file: { allowedDecisions: ["approve", "reject"] },

  // Must approve (no rejection allowed)
  critical_operation: { allowedDecisions: ["approve"] },
};

인터럽트 처리 (Handle interrupts)

인터럽트가 트리거되면 에이전트가 실행을 일시 중지하고 제어권을 반환합니다. 결과에서 인터럽트를 확인하고 그에 따라 처리하세요. 사용자가 동작을 거부하면, 도구가 실행되지 않았고 다음에 무엇을 해야 하는지 에이전트에게 알려주는 명확한 message를 포함하세요.

import { v7 as uuid7 } from "uuid";
import { Command } from "@langchain/langgraph";

// Create config with thread_id for state persistence
const config = { configurable: { thread_id: uuid7() } };

// Invoke the agent
let result = await agent.invoke({
  messages: [{ role: "user", content: "Delete the file temp.txt" }],
}, config);

// Check if execution was interrupted
if (result.__interrupt__) {
  // Extract interrupt information
  const interrupts = result.__interrupt__[0].value;
  const actionRequests = interrupts.actionRequests;
  const reviewConfigs = interrupts.reviewConfigs;

  // Create a lookup map from tool name to review config
  const configMap = Object.fromEntries(
    reviewConfigs.map((cfg) => [cfg.actionName, cfg])
  );

  // Display the pending actions to the user
  for (const action of actionRequests) {
    const reviewConfig = configMap[action.name];
    console.log(`Tool: ${action.name}`);
    console.log(`Arguments: ${JSON.stringify(action.args)}`);
    console.log(`Allowed decisions: ${reviewConfig.allowedDecisions}`);
  }

  // Get user decisions (one per actionRequest, in order)
  const decisions = [
    {
      type: "reject",
      message: "User rejected deleting temp.txt. Do not retry deletion.",
    }
  ];

  // Resume execution with decisions
  result = await agent.invoke(
    new Command({ resume: { decisions } }),
    config  // Must use the same config!
  );
}

// Process final result
console.log(result.messages[result.messages.length - 1].content);

여러 도구 호출 (Multiple tool calls)

에이전트가 승인이 필요한 여러 도구를 호출하면 모든 인터럽트가 단일 인터럽트로 배치됩니다. 각각에 대해 순서대로 결정을 제공해야 합니다.

const config = { configurable: { thread_id: uuid7() } };

let result = await agent.invoke({
  messages: [{
    role: "user",
    content: "Delete temp.txt and send an email to [email protected]"
  }]
}, config);

if (result.__interrupt__) {
  const interrupts = result.__interrupt__[0].value;
  const actionRequests = interrupts.actionRequests;

  // Two tools need approval
  console.assert(actionRequests.length === 2);

  // Provide decisions in the same order as actionRequests
  const decisions = [
    { type: "approve" },  // First tool: delete_file
    {
      type: "reject",
      message: "User rejected this action. Do not retry this tool call.",
    }  // Second tool: send_email
  ];

  result = await agent.invoke(
    new Command({ resume: { decisions } }),
    config
  );
}

거부 메시지 (Rejection messages)

검토자가 reject 결정을 반환하면 Deep Agents는 도구 호출을 건너뛰고 거부 피드백을 에이전트에 보냅니다. message를 생략하면 기본 피드백이 모델에게 도구가 실행되지 않았고 사용자가 요청하지 않는 한 같은 도구 호출을 재시도하지 말라고 알립니다.

민감하거나 부작용이 있는 도구의 경우 결정과 함께 도메인별 message를 전달하세요. 에이전트가 동작을 포기해야 하는지, 후속 질문을 해야 하는지, 더 안전한 대안을 시도해야 하는지 명시적으로 밝히세요.

const decisions = [
  {
    type: "reject",
    message: "User rejected deleting this file. Do not retry deletion. Ask which file to archive instead.",
  },
];

도구 인자 편집 (Edit tool arguments)

"edit"이 허용 결정에 있으면 실행 전에 도구 인자를 수정할 수 있습니다:

if (result.__interrupt__) {
  const interrupts = result.__interrupt__[0].value;
  const actionRequest = interrupts.actionRequests[0];

  // Original args from the agent
  console.log(actionRequest.args);  // { to: "[email protected]", ... }

  // User decides to edit the recipient
  const decisions = [{
    type: "edit",
    editedAction: {
      name: actionRequest.name,  // Must include the tool name
      args: { to: "[email protected]", subject: "...", body: "..." }
    }
  }];

  result = await agent.invoke(
    new Command({ resume: { decisions } }),
    config
  );
}

서브에이전트 인터럽트 (Subagent interrupts)

서브에이전트를 사용할 때 도구 호출에 인터럽트도구 호출 내부에 인터럽트를 사용할 수 있습니다.

도구 호출에 인터럽트

각 서브에이전트는 메인 에이전트의 설정을 재정의하는 자체 interrupt_on 구성을 가질 수 있습니다:

const agent = createDeepAgent({
  tools: [deleteFile, readFile],
  interruptOn: {
    delete_file: true,
    read_file: false,
  },
  subagents: [{
    name: "file-manager",
    description: "Manages file operations",
    systemPrompt: "You are a file management assistant.",
    tools: [deleteFile, readFile],
    interruptOn: {
      // Override: require approval for reads in this subagent
      delete_file: true,
      read_file: true,  // Different from main agent!
    }
  }],
  checkpointer
});

서브에이전트가 인터럽트를 트리거하면 처리는 동일합니다 — 결과에서 interrupts를 확인하고 Command로 재개하세요.

도구 호출 내부의 인터럽트

서브에이전트 도구는 interrupt()를 직접 호출해 실행을 일시 중지하고 승인을 기다릴 수 있습니다:

import { createAgent, tool } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";
import { MemorySaver, Command, interrupt } from "@langchain/langgraph";
import { createDeepAgent } from "deepagents";
import { z } from "zod";

const requestApproval = tool(
  async ({ actionDescription }: { actionDescription: string }) => {
    const approval = interrupt({
      type: "approval_request",
      action: actionDescription,
      message: `Please approve or reject: ${actionDescription}`,
    }) as { approved?: boolean; reason?: string };

    if (approval.approved) {
      return `Action '${actionDescription}' was APPROVED. Proceeding...`;
    } else {
      return `Action '${actionDescription}' was REJECTED. Reason: ${
        approval.reason || "No reason provided"
      }`;
    }
  },
  {
    name: "request_approval",
    description: "Request human approval before proceeding with an action.",
    schema: z.object({
      actionDescription: z
        .string()
        .describe("The action that requires approval"),
    }),
  }
);

async function main() {
  const checkpointer = new MemorySaver();
  const model = new ChatOpenAI({
    model: "gpt-5.4-mini",
    maxTokens: 4096,
  });

  const compiledSubagent = createAgent({
    model: model,
    tools: [requestApproval],
    name: "approval-agent",
  });

  const parentAgent = await createDeepAgent({
    checkpointer: checkpointer,
    subagents: [
      {
        name: "approval-agent",
        description: "An agent that can request approvals",
        runnable: compiledSubagent as any,
      },
    ],
  });

  const threadId = "test_interrupt_directly";
  const config = { configurable: { thread_id: threadId } };

  console.log("Invoking agent - sub-agent will use request_approval tool...");

  let result = await parentAgent.invoke(
    {
      messages: [
        new HumanMessage({
          content:
            "Use the task tool to launch the approval-agent sub-agent. " +
            "Tell it to use the request_approval tool to request approval for 'deploying to production'.",
        }),
      ],
    },
    config
  );

  if (result.__interrupt__) {
    const interruptValue = result.__interrupt__[0].value as {
      type?: string;
      action?: string;
      message?: string;
    };
    console.log("\nInterrupt received!");
    console.log(`  Type: ${interruptValue.type}`);
    console.log(`  Action: ${interruptValue.action}`);
    console.log(`  Message: ${interruptValue.message}`);

    console.log("\nResuming with Command(resume={'approved': true})...");
    const result2 = await parentAgent.invoke(
      new Command({ resume: { approved: true } }),
      config
    );

    if (!result2.__interrupt__) {
      console.log("\nExecution completed!");
      // Find the tool response
      const toolMsgs = result2.messages?.filter((m) => m.type === "tool") || [];
      if (toolMsgs.length > 0) {
        const lastToolMsg = toolMsgs[toolMsgs.length - 1];
        console.log(`  Tool result: ${lastToolMsg.content}`);
      }
    } else {
      console.log("\nAnother interrupt occurred");
    }
  } else {
    console.log(
      "\n  No interrupt - the model may not have called request_approval"
    );
  }
}

main().catch(console.error);

실행하면 다음과 같은 출력이 생성됩니다:

Invoking agent - sub-agent will use request_approval tool...

Interrupt received!
  Type: approval_request
  Action: deploying to production
  Message: Please approve or reject: deploying to production

Resuming with Command(resume={'approved': true})...

Execution completed!
  Tool result: Approval for "deploying to production" has been granted. You can proceed with the deployment.

더 알아보기