4.6 数据对话应用 · shinychat & querychat
4.6 数据对话应用 · shinychat & querychat
本章目标
完成本章后,你能够:
- evaluate 聊天框作为 UI 的得失:什么任务适合对话、什么任务必须控件
- build 用
page_chat()+chat_server()搭最小聊天应用,并把工具接成 UI 事件 - implement 每会话独立的 ellmer client,杜绝用户间历史串线
- apply 四类护栏:system prompt 锚定、工具范围收窄、拒绝体验、路径校验
- assess querychat 的 NL→SQL 安全模型,并说出为什么永远不能 eval 模型原文
前置自测
1. 聊天框是一种什么样的界面
chat-as-UI 的本质:把「说清需求」的成本从设计者转嫁给用户——探索型 任务纯赚,精确重复任务纯亏(chat 比按钮慢且不可审计)。
| 任务形态 | 界面 | 理由 |
|---|---|---|
| 开放探索、追问式分析 | chat | 需求说不全,对话能补 |
| 精确、高频、要审计 | 传统控件 | 每次结果必须逐字节一致 |
| 两者混合 | chat 入口 + 控件收口 | 问出来 → 存成参数化报表 |
先问三个问题:用户知道想问什么吗(不知道→控件引导);答案要进正式流程吗 (要→可复现的控件产物);出错谁负责(对话不可回放,控件日志可审计)。 三个都答「不利」,就别做 chat。
2. 最小 shinychat 应用
shinychat 把聊天界面拆成一对组件:UI 侧 page_chat(),服务器侧 chat_server()(改编自 llms 24_shinychat-1): 本章在上游 llms 项目根目录运行,使用配套 _agent.R、blockbuster/、skills/ 与 data/。
library(ellmer)
library(shiny)
library(shinychat)
activity_dir <- here::here("_solutions/24_shinychat-1")
project_dir <- file.path(activity_dir, "blockbuster")
skills_dir <- file.path(activity_dir, "skills")
source(file.path(activity_dir, "_agent.R"))
greeting <- paste(
"## Welcome to the renewal desk\n\nChoose a starting point, or write your own request.\n",
"* <span class=\"suggestion submit\">Draft renewal letters for the top three lapsed members.</span>",
"* <span class=\"suggestion submit\">List the draft letters already in the workspace.</span>",
"* <span class=\"suggestion submit\">Explain the renewal-letter skill.</span>",
sep = "\n"
)
ui <- page_chat(
"Blockbuster renewal assistant",
id = "chat",
greeting = chat_greeting(greeting),
placeholder = "Ask about the renewal campaign..."
)
server <- function(input, output, session) {
client <- chat_posit()
# 4.4 章的接线:Blockbuster system prompt + 文件工具 + skills
prep_blockbuster_agent(client, project_dir, skills_dir)
chat_server("chat", client) # 流式输出、输入框、会话历史全由它接管
}
shinyApp(ui, server)UI 细节:chat_greeting() 里可用 Markdown,<span class="suggestion submit"> 渲染成可点击的建议卡片——新用户的第一句话最难,卡片替他问出口。
3. 会话状态:每个用户一台独立对话机
§2 里 chat_posit() 在 server() 内部创建。server 函数每个用户会话 各跑一份,于是每个浏览器标签页拿到自己的 ellmer client 和对话历史—— 这是特性不是巧合:
全应用共用一个会话对象——A 的历史出现在 B 的上下文里(隐私事故), 所有人的轮次还互相抬价(4.1 的历史重发计费)。规则:一 session 一 client, 每会话独立计费,建议卡片就是把第一句话引上便宜轨道的第一道闸。
开两个浏览器窗口连本地 app,各自问一个问题,再各问一句「我刚才问了什么?」 验证:每个窗口只记得自己的历史。再把 client <- chat_posit() 移到全局环境 复跑一次,观察串线,用 4.1 的「模型无记忆,ellmer 重发历史」解释现象。
4. 工具即 UI 事件:让模型操作界面
聊天应用真正的杠杆:模型调用工具的瞬间,恰好是 R 在跑代码的瞬间——顺手 就能更新 Shiny UI。25_shinychat-2 加了「草稿抽屉」:模型写完信,调用工具 把信展示到抽屉里供人编辑(UI 在 page_chat() 里加 drawer = chat_drawer(...)):
drafts_dir <- file.path(project_dir, "letters", "drafts")
ui <- page_chat(
"Blockbuster renewal assistant", id = "chat",
greeting = chat_greeting("## Welcome to the renewal desk\n\nDraft letters, then review them in the drawer."),
placeholder = "Ask about the renewal campaign...",
drawer = chat_drawer(
selectInput("letter", "Draft", choices = character()),
bslib::input_code_editor("letter_content", label = "Letter",
language = "markdown", height = "500px"),
actionButton("save_letter", "Save letter"),
title = "Letter drafts", open = FALSE
)
)
server <- function(input, output, session) {
show_letter <- function(path) {
filename <- basename(path) # 剥掉一切路径
files <- sort(list.files(drafts_dir, pattern = "\\.md$"))
if (!filename %in% files) {
stop("Could not find draft ", filename, ".") # 拒绝,而不是猜
}
updateSelectInput(session, "letter", choices = files, selected = filename)
chat_drawer_show("chat", session = session) # 打开抽屉
paste0("Opened ", filename, " in the letter drawer.") # 回给模型的回执
}
tool_show_letter <- tool(
show_letter,
description = "Open a draft renewal letter in the app's letter drawer.",
arguments = list(
path = type_string("Path to a draft letter in letters/drafts/.")
)
)
client <- chat_posit()
client$register_tool(tool_show_letter)
prep_blockbuster_agent(client, project_dir, skills_dir)
chat_server("chat", client)
draft_files <- reactive({
invalidateLater(1000, session)
sort(list.files(drafts_dir, pattern = "\\.md$"))
})
observe({
files <- draft_files()
selected <- if (!is.null(input$letter) && input$letter %in% files) {
input$letter
} else if (length(files)) {
files[[1]]
} else {
NULL
}
updateSelectInput(session, "letter", choices = files, selected = selected)
})
observeEvent(input$letter, {
req(input$letter)
bslib::update_code_editor(
"letter_content",
value = brio::read_file(file.path(drafts_dir, input$letter))
)
})
observeEvent(input$save_letter, {
req(input$letter)
brio::write_file(input$letter_content, file.path(drafts_dir, input$letter))
})
}
shinyApp(ui, server)读三点:basename() + 白名单把模型路径降级成受控文件名;失败 stop() 让 模型收到错误自行重试;工具的返回串是给模型的回执,用户看到的是抽屉开了。
5. 面向用户的护栏与上线
终端里的 agent 用户是你自己;进了应用,用户是任何人。四条最低配置:1. 锚定:system prompt 声明身份、数据边界与「工具之外一律不做」 2. 收窄:副作用只能经注册的工具发生,路径锁死在 project_dir 内 3. 拒绝体验:范围外的问题礼貌回「这不在我的工具范围」,refusal 是功能 4. 输入校验:模型的每个参数都当不可信用户输入处理(§4 的 basename() 模式)
上线三件事:去处选 Posit Connect(企业内,服务器级 key 与访问控制)或 shinyapps.io(rsconnect 一键推送);API key 放服务器环境变量,绝不进代码; 上线前用 4.1 的成本直觉压估(每会话成本 × 并发数)。
八成的数据应用只需要三个下拉框和一个按钮。聊天框适合剩下那两成「用户自己 也不知道想看什么」的探索——先做控件版,等观察到用户真的开始「连着问」 了再包一层 chat。反过来做几乎总是过度工程。
6. querychat:自然语言到安全查询
shinychat 给你砖头,querychat 给你整栋楼:数据框进去,带聊天、数据、SQL 三视图的应用出来(改编自 llms 26_querychat):
library(ellmer)
library(querychat)
airbnb_data <- read.csv(here::here("data/airbnb-austin.csv"))
qc <- QueryChat$new(
airbnb_data,
"airbnb_data",
client = chat_posit(),
greeting = "Ask me about Austin Airbnb listings.",
data_dict = here::here("data/airbnb-austin_data-dict.yaml")
)
qc$app()上手动作:问「Which neighborhood has the most private rooms?」,打开数据 抽屉选 Show Query——生成的 SQL 在那里,点开即可审计;再追问「那个街区里 最便宜的私房呢?」,体会会话内上下文如何被带回查询。data_dict(YAML 数据字典)逐列告诉模型真实含义,是回答质量最便宜的放大器。
任何把模型回复拼成 R 代码再 eval()/parse() 执行的方案,都是把服务器钥匙 交给输入框。querychat 的做法值得抄:约束模型只生成针对单表的 SQL,交给受限 引擎执行——查询是可审视的数据操作,不是可执行代码。通用姿势二选一: 受约束的 DSL(SQL),或让模型产出结构化参数(4.2 的 chat_structured()) 由你的 R 函数安全执行。模型给参数,代码给动作。
用你自己的数据集复刻 §2:page_chat() + 三张建议卡片 + 定制 placeholder。 卡片覆盖「一个好问题、一个清单问题、一个元问题(问应用能干什么)」。 (改编自 llms 工作坊 24_shinychat-1)
给 §2 的应用加一个你自己的 UI 工具(参照 §4):让模型把某次分析结果「钉」到 侧边栏(chat_drawer() 或一个 valueBox)。硬性要求:basename() 等价校验 + 白名单 + 失败 stop();演示一次模型传非法值时应用如何安全拒绝。 (改编自 llms 工作坊 25_shinychat-2)
第一轮(全程禁用 AI):假设应用要公开发布,手写威胁清单(≥10 条): 数据里的提示词注入、路径穿越、PII 泄漏、成本攻击……每条写 「入口 → 后果 → 一句缓解」。 第二轮(开放 AI):把清单交给助手做红队评审,只问:「哪三条我最可能 低估?给出攻击演示请求。」补齐后实测其中两条,记录应用的行为。
Capstone · 压轴项目
任务:「领域数据对话应用」——二选一:① shinychat 路线:你的数据 + 锚定 system prompt + ≥1 个带校验的 UI 工具 + 建议卡片开局;② querychat 路线:你的数据 + 手写 data_dict。都要交付:部署说明(Connect 或 shinyapps) + 10 问测试脚本(含 2 个范围外问题的拒绝记录)+ 每会话成本压估。
| 维度 | 达到 | 良好 | 卓越 |
|---|---|---|---|
| 交互设计 | 应用能跑、流式输出 | 建议卡片引导第一问 | 对话产物可「收口」为控件/报表 |
| 安全护栏 | client 按会话隔离 | 工具全带校验+白名单 | 威胁清单逐条映射到实现并实测两条 |
| 状态与成本 | 会话互不串线 | 成本压估有分轮计算 | 给出并发上限估算或限流建议 |
| 证据边界 | 范围外问题有拒绝 | 拒绝话术礼貌且指路 | 测试脚本含对抗性请求的记录 |
SOURCES · 来源映射
| 讲义节 | 素材 | 性质 |
|---|---|---|
| §2 最小应用与建议卡片、§4 抽屉与 UI 工具 | posit::conf(2026) llms _exercises/24_shinychat-1、25_shinychat-2 及对应 _solutions/(Garrick Aden-Buie, Sara Altman · CC-BY-SA 4.0) |
改编 |
| §6 querychat 用法与 Show Query | llms _exercises/26_querychat / _solutions/26_querychat |
改编 |
| chat-as-UI 得失表、护栏四条、eval 红线论述、练习改造、capstone、rubric | 本项目 | 原创 |
本章以 CC-BY-SA 4.0 发布。