LangGraph 16:Studio 接入

前言

手写 invoke 与 Command(resume=...) 能把 HITL 跑通,但看不清暂停点,也不方便在界面里点选续跑。
旧版 macOS 桌面 Studio 已经停更,不要再下 .dmg 。
现在用浏览器里的 LangSmith Studio,本地起 CLI 开发服务器即可。
本文只讲接入与图形界面联调,不重复 interrupt 语义。
动态 HITL 协议见 《LangGraph 11:HITL 人机协同》。
产品里要自己的审批页,见 《LangGraph 17:自建中断面板》。
下文需要 Python 3.12+,依赖用 uv;联调需要可登录的 LangSmith 账号(免费档可用)。

概要

先装 CLI、写 langgraph.json,把 compile() 后的图导出给 Studio。
再用两张互不依赖的示例图分别练填表中断和工具审批。
最后只在图形界面里走完 resume,不必再敲联调命令。

依赖

建议使用 Python 3.12 及以上。
用 uv 初始化工程,并加上内存版开发服务器。
[inmem] 适合本地调试,一般不必先装 Docker。

1
2
3
4
5
uv init langgraph-studio
cd langgraph-studio
uv venv --python 3.12
uv add "langgraph>=1.0,<2.0" "langchain>=1.0,<2.0" langchain-openai python-dotenv
uv add "langgraph-cli[inmem]"

在项目根目录创建 .env ,写入 Coding Plan 的 Key 与专用 Base URL。
收集资料那张图不调模型;工具审批那张图会调 ark-code-latest 。

1
2
3
4
OPENAI_API_KEY=你的火山方舟 API Key
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3

LANGSMITH_API_KEY=lsv2_pt_xxx

请勿把 Base URL 写成普通方舟 .../api/v3 ,以免无法抵扣 Coding Plan 额度。

配置

用项目根目录的 langgraph.json 声明图入口与环境文件。
路径按仓库实际结构调整。

1
2
3
4
5
6
7
8
{
"dependencies": ["."],
"graphs": {
"graph": "./src/agent.py:graph",
"chat_graph": "./src/chat_agent.py:chat_graph"
},
"env": ".env"
}

graphs 是「Studio 里的图名 → 某个 py 里导出的编译图」。
这里挂了两张示例图,只为在左上角切换着看两种 HITL,不是运行所必需。

  1. graph 来自 agent.py :不调模型,只练同一节点里连续三次 interrupt 填表。
  2. chat_graph 来自 chat_agent.py :对话里拦工具调用,练 approve / edit / reject ,要走 Coding Plan。

只想先看中断面板时,配置里删掉 chat_graph 那一行、只留 agent.py 即可。
业务项目通常一张图一个入口,不必为了 Studio 故意拆两个文件。

先建 src 目录,再按你启用的图放入对应模块,路径与 langgraph.json 一致。
Studio 只导入编译后的图,不要在模块顶层调用 invoke 。
langgraph dev 会注入平台自己的 checkpointer,这里必须 compile() ,不要再传 InMemorySaver ,否则会直接报错退出。

示例

收集资料图

同一节点内连续三次 interrupt ,收集姓名、年龄、性别。
每次 resume 只推进一步,可在 Studio 里分步填写。
把下面内容保存为 src/agent.py ,导出变量名必须是 graph 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from typing import TypedDict

from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt


class OverAllState(TypedDict):
username: str
age: int
gender: str


def collect_profile(state: OverAllState) -> OverAllState:
username = interrupt("请输入您的姓名:")
age = interrupt("请输入您的年龄:")
gender = interrupt("请输入您的性别:")
return {"username": username, "age": age, "gender": gender}


builder = StateGraph(state_schema=OverAllState)
builder.add_node("collect_profile", collect_profile)
builder.add_edge(START, "collect_profile")
builder.add_edge("collect_profile", END)

graph = builder.compile()

工具审批图

自定义 tool_node :把本轮全部 tool_calls 打进一次 interrupt 。
Studio 里对每条调用回复 approve / reject / edit 。
把下面内容保存为 src/chat_agent.py ,导出变量名必须是 chat_graph 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import os
from typing import Any, Literal

from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain.messages import ToolMessage
from langchain.tools import tool
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.types import interrupt

load_dotenv()


@tool(parse_docstring=True)
def get_weather(city: str) -> str:
"""根据城市查询当日天气。

Args:
city: 城市名称
"""
return f"{city} 今天天气不错"


@tool(parse_docstring=True)
def get_time(city: str) -> str:
"""根据城市查询当地时间。

Args:
city: 城市名称
"""
return f"{city} 现在是下午 3 点"


tools = [get_weather, get_time]
tool_map = {t.name: t for t in tools}

model = init_chat_model(
"openai:ark-code-latest",
temperature=0,
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ["OPENAI_BASE_URL"],
).bind_tools(tools)


def model_node(state: MessagesState) -> MessagesState:
return {"messages": [model.invoke(state["messages"])]}


def tool_node(state: MessagesState) -> MessagesState:
pending = state["messages"][-1].tool_calls
decisions = interrupt(
{
"instruction": "请审批本轮工具调用",
"actions": ["approve", "reject", "edit"],
"tool_calls": pending,
}
)
if isinstance(decisions, dict):
decisions = [decisions]
by_id = {item["id"]: item for item in decisions}

messages = []
for call in pending:
item = by_id.get(call["id"], {"action": "reject", "reason": "未给出审批结果"})
action = item.get("action")
if action == "reject":
messages.append(
ToolMessage(
content=f"已拒绝:{item.get('reason', '人工拒绝')}",
tool_call_id=call["id"],
name=call["name"],
)
)
continue
args: dict[str, Any] = item.get("args", call["args"]) if action == "edit" else call["args"]
result = tool_map[call["name"]].invoke(args)
messages.append(
ToolMessage(
content=str(result),
tool_call_id=call["id"],
name=call["name"],
)
)
return {"messages": messages}


def router(state: MessagesState) -> Literal["tool_node", "__end__"]:
return "tool_node" if state["messages"][-1].tool_calls else END


builder = StateGraph(state_schema=MessagesState)
builder.add_node("model_node", model_node)
builder.add_node("tool_node", tool_node)
builder.add_edge(START, "model_node")
builder.add_conditional_edges("model_node", router, path_map=["tool_node", END])
builder.add_edge("tool_node", "model_node")

chat_graph = builder.compile()

暂停后,把下面这种列表填进 Studio 的 resume 编辑器。
id 必须与本次 payload 里各条 tool_calls 的 id 对应; edit 时用 args 覆盖原参数。

1
2
3
4
5
[
{"id": "call_weather", "action": "approve"},
{"id": "call_time", "action": "edit", "args": {"city": "上海"}},
{"id": "call_other", "action": "reject", "reason": "不执行"}
]

启动

在项目根目录启动开发服务器。
终端里会打印 API 地址和 Studio 链接,之后的联调都在浏览器里完成。

1
uv run langgraph dev

默认会起本地 API http://127.0.0.1:2024 。
浏览器打开 Studio: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 。

Safari 或 Brave 若拦 localhost ,可改走隧道:

1
uv run langgraph dev --tunnel

HITL 仍依赖 checkpointer,但由 langgraph dev 注入,不要在 Studio 用的图里自己挂 InMemorySaver 。

测试

服务器起来后,只在 Studio 图形界面里测。
下面两张图分别测: graph 收集资料, chat_graph 工具审批。

测收集资料

用来确认同一节点里连续三次 interrupt 都能在界面里 resume。

  1. 浏览器打开上面的 Studio 地址,用 LangSmith 登录。
  2. 确认已连上本地服务器(地址栏带 baseUrl=http://127.0.0.1:2024 )。
  3. 左上角图列表里选 graph 。
  4. 需要的话切到 Graph 视图,不要用 Chat。
  5. 点新建线程,输入框填 {} ,点提交。
  6. 图会停在 collect_profile ,界面出现中断面板,payload 是「请输入您的姓名:」。
  7. 在 resume 输入框填 小明 ,点继续。
  8. 第二次中断填年龄,例如 18 ,再继续。
  9. 第三次中断填性别,例如 男 ,再继续。
  10. 跑完后在状态面板查看,应有 username 、 age 、 gender 三个字段。

三次 resume 必须停在同一条线程里点继续,不要新建线程。
年龄填数字或字符串都可以,界面会按你提交的值原样写回状态。

测工具审批

用来确认工具调用会在界面里暂停,并能按 approve / edit / reject 续跑。
.env 里的 Coding Plan Key 要可用,否则模型节点过不去。

  1. 左上角改选 chat_graph 。
  2. 点新建线程,切到 Chat 视图。
  3. 在底部对话框输入「北京天气怎么样,现在几点了」,发送。
  4. 模型若产出 tool_calls ,图会停在 tool_node ,中断面板能看到 instruction 和 tool_calls 。
  5. 从本次 payload 里抄出各条 tool_calls 的 id ,不要用文中占位符。
  6. 在 resume 编辑器里填审批列表,例如一条 approve ,点继续。
  7. 图会执行工具并回到 model_node ,对话区应出现工具结果和模型回复。

可再开一条线程,把其中一条改成 edit (改 args.city )或 reject (带 reason ),对比状态里的 ToolMessage 。
resume 列表格式仍用上一节的 JSON,只把 id 换成界面里看到的值。
生产环境把同一套「中断面板 payload + resume」接到 Web 表单即可,做法见 《LangGraph 17:自建中断面板》。

总结

  1. 旧桌面安装包已停更;装 langgraph-cli[inmem] ,写 langgraph.json ,用 langgraph dev 打开浏览器 Studio。
  2. 给 Studio 的图只 compile() ,不要挂 InMemorySaver 。
  3. graphs 里可以只挂一张图;两份 py 只是两种 HITL 示例,不是成套依赖。
  4. 界面里选图、提交、在中断面板 resume,同一条线程点继续。
  5. interrupt 语义仍以 《LangGraph 11:HITL 人机协同》 为准。