给文字游戏智能体加上可验证边界:候选动作、模型重排与强化学习回放

简介: 本文提出一种可验证的混合智能体框架,将语言模型(语义重排候选动作)与表格型Q-learning(真实交互更新价值)解耦,通过环境提供合法动作、模型仅排序、强化学习优化长期回报,并辅以结构化日志与严格评估,提升文字游戏中的样本效率与可复现性。(239字)

互动文字游戏看起来只是“阅读一段文字,再输入一句命令”,实际上同时包含状态理解、目标规划和序列决策。智能体可能需要先拿钥匙、再开门,或者检查容器后才能取得目标物品;某一步即时奖励为零,并不代表该动作没有长期价值。

直接让语言模型自由生成命令,常见问题是输出环境不接受的表达、重复无效操作,或者在上下文增长后偏离目标。只使用表格型 Q-learning 也有明显限制:自然语言观测几乎每一步都不同,状态空间稀疏,随机探索很难迅速发现有意义的动作。

一种更容易验证的做法是把职责拆开:

  1. 环境提供当前允许执行的候选命令,负责动作合法性。
  2. 语言模型只对候选命令排序,不直接创造动作。
  3. 强化学习根据环境返回的真实奖励更新长期价值。
  4. 轨迹日志保存状态、候选、选择和奖励,供失败复盘与离线评估使用。

这里的重点不是断言混合方案一定胜过某种基线,而是建立可测量、可替换的实验框架。最终效果仍取决于游戏结构、奖励密度、候选动作质量、模型能力和训练预算,必须通过固定任务集验证。

原理:将语言先验与长期回报分离

设状态为 s,环境给出的合法动作集合为 A(s)。表格型 Q-learning 的更新为:

Q(s, a) ← Q(s, a) + α[r + γ max Q(s', a') - Q(s, a)]

其中 α 是学习率,γ 是折扣因子。Q 值来自实际交互,不应由语言模型伪造。语言模型承担的是候选重排:根据当前描述、背包内容和历史动作,从 A(s) 中选出语义上更可能推进目标的一项。

执行时仍采用 ε-greedy 策略:以概率 ε 探索,否则选择当前 Q 值最高的动作。探索分支可以使用模型重排代替完全随机选择。这样既保留探索,又能利用语言先验减少明显无意义的尝试。随着训练推进逐步降低 ε,策略会更多依赖已经获得的回报证据。

自然语言状态不能直接无限存入 Q 表。示例将描述、背包和最近动作规范化后计算摘要,作为实验用状态键。这会产生状态混叠:两段文本相同但隐藏状态不同的场景可能被视为同一状态。更严谨的实验可以加入任务标识、已访问房间、已完成事件或环境公开的结构化字段。

环境与接入准备

先创建隔离环境并安装依赖:

python -m venv .venv
source .venv/bin/activate
pip install textworld requests

TextWorld 的安装条件和可用游戏格式可能随当前发行包及操作系统而异,应以项目当前文档为准。准备一个可由本机 TextWorld 打开的游戏文件,并通过命令行参数传给脚本。

模型服务通过环境变量配置,密钥不写入源码:

export LLM_API_URL='https://your-endpoint.example/v1/chat/completions'
export LLM_MODEL='your-model-id'
export LLM_API_KEY='replace-with-your-secret'

如果需要比较不同模型或中转接口,可以把端点封装在同一个适配层中。例如,在确认其当前接口文档与请求格式符合项目要求后,可将 HaerAPI 作为候选接入端点之一;本文示例只假设一种常见的类 Chat Completions JSON 结构,不代表任意服务都支持该路径或字段。

实现受约束的混合智能体

下面的脚本包含四个关键边界:只允许模型返回候选序号;解析失败时退回随机候选;网络请求设置超时;每一步写入 JSONL 轨迹。不同 TextWorld 包装器的 resetstep 返回结构可能不同,代码针对 textworld.start 的经典状态对象接口,若本地接口不同应按当前文档调整适配处。

import argparse
import hashlib
import json
import os
import random
from collections import defaultdict

import requests
import textworld
from textworld import EnvInfos

ALPHA = 0.2
GAMMA = 0.95


def state_key(state, recent_actions):
    payload = {
   
        "description": getattr(state, "description", ""),
        "inventory": getattr(state, "inventory", ""),
        "recent_actions": recent_actions[-3:],
    }
    raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def model_pick(state, actions):
    url = os.environ.get("LLM_API_URL")
    model = os.environ.get("LLM_MODEL")
    api_key = os.environ.get("LLM_API_KEY")
    if not all([url, model, api_key]):
        return random.randrange(len(actions)), "random_no_config"

    prompt = {
   
        "goal": "Choose one legal action that is most likely to advance the game.",
        "description": getattr(state, "description", ""),
        "inventory": getattr(state, "inventory", ""),
        "actions": [{
   "index": i, "command": a} for i, a in enumerate(actions)],
        "output_rule": "Return JSON only: {\"index\": integer}",
    }
    body = {
   
        "model": model,
        "temperature": 0,
        "messages": [{
   "role": "user", "content": json.dumps(prompt)}],
    }

    try:
        response = requests.post(
            url,
            headers={
   "Authorization": f"Bearer {api_key}"},
            json=body,
            timeout=(5, 30),
        )
        response.raise_for_status()
        content = response.json()["choices"][0]["message"]["content"]
        index = int(json.loads(content)["index"])
        if 0 <= index < len(actions):
            return index, "model"
    except (requests.RequestException, KeyError, ValueError, TypeError, json.JSONDecodeError):
        pass

    return random.randrange(len(actions)), "random_fallback"


def train(game_file, episodes, max_steps, log_file):
    infos = EnvInfos(
        admissible_commands=True,
        description=True,
        inventory=True,
        won=True,
        lost=True,
    )
    env = textworld.start(game_file, infos)
    q = defaultdict(float)

    with open(log_file, "a", encoding="utf-8") as log:
        for episode in range(episodes):
            state = env.reset()
            recent = []
            epsilon = max(0.1, 1.0 - episode / max(1, episodes - 1))

            for step in range(max_steps):
                actions = list(getattr(state, "admissible_commands", []) or [])
                if not actions:
                    break

                key = state_key(state, recent)
                if random.random() < epsilon:
                    index, source = model_pick(state, actions)
                else:
                    index = max(range(len(actions)), key=lambda i: q[(key, actions[i])])
                    source = "q_table"

                action = actions[index]
                next_state, reward, done = env.step(action)
                next_actions = list(getattr(next_state, "admissible_commands", []) or [])
                next_key = state_key(next_state, recent + [action])
                future = max((q[(next_key, a)] for a in next_actions), default=0.0)
                old = q[(key, action)]
                q[(key, action)] = old + ALPHA * (reward + GAMMA * future - old)

                record = {
   
                    "episode": episode,
                    "step": step,
                    "state_key": key,
                    "action": action,
                    "source": source,
                    "reward": reward,
                    "done": done,
                    "won": bool(getattr(next_state, "won", False)),
                    "lost": bool(getattr(next_state, "lost", False)),
                }
                log.write(json.dumps(record, ensure_ascii=False) + "\n")
                log.flush()

                recent.append(action)
                state = next_state
                if done:
                    break

    env.close()


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("game_file")
    parser.add_argument("--episodes", type=int, default=20)
    parser.add_argument("--max-steps", type=int, default=100)
    parser.add_argument("--log", default="trajectories.jsonl")
    args = parser.parse_args()
    train(args.game_file, args.episodes, args.max_steps, args.log)

执行方式如下:

python agent.py ./games/example.ulx --episodes 20 --max-steps 100

默认值只是让流程能够运行的起点,不是经过证明的最优参数。正式实验应固定游戏文件、随机种子、步数上限和评估预算,并把参数写入实验记录。

如何做可复核评估

不要只看某一局是否通关。至少建立三组策略:完全随机候选、纯 ε-greedy Q-learning、模型辅助探索。每组使用相同任务集合与最大步数,并分别记录通关与否、累计环境奖励、执行步数、无效终止、模型调用次数和接口失败次数。

训练轨迹与评估轨迹应分开。评估阶段固定策略,不再更新 Q 表;否则不同策略经历的数据不同,结果很难公平解释。任务数量不足时,不宜依据少量成功案例推出普遍结论。若游戏会暴露候选命令,实验结论也只能说明“候选集合内的决策能力”,不能等同于开放式自然语言规划能力。

接口侧还应记录耗时、HTTP 状态类别和解析结果,但不要把密钥、完整认证头或含敏感数据的原始提示写入日志。涉及真实业务文本时,需要先确定数据分级、脱敏规则、留存周期和供应商处理条款。

常见问题

模型返回了解释文字,JSON 无法解析

提示词约束不能代替程序校验。生产实现可以在服务确实支持时使用结构化输出能力,但仍要验证类型与范围。解析失败必须回退到合法候选,不能把原始文本直接交给环境执行。

智能体不断在两个房间之间往返

状态键缺少历史信息,或者环境奖励过于稀疏。可以增加最近若干动作、已访问位置和关键物品状态,也可以对短周期重复动作加入小幅惩罚。惩罚会改变优化目标,应在实验配置中明确记录,不能把塑形奖励当成环境原始奖励。

Q 表几乎无法复用

描述中的分数、步数或措辞变化会制造大量不同摘要。应先规范化文本,或者改用环境提供的结构化事实。进一步使用神经网络近似 Q 值时,还要处理经验回放、目标网络与训练稳定性,不能直接把表格实现的结论外推过去。

接口超时导致训练停滞

为连接和读取分别设置超时,并采用有限次数、带抖动的退避重试。训练循环应能够降级为随机探索,同时记录降级原因。是否重试 4295xx 要结合服务条款和响应头,避免无上限重放。

每一步都调用模型,成本不可控

只在探索分支、首次遇到某状态或 Q 值差距很小时调用,并缓存“规范化状态加候选集合”的排序结果。缓存键应包含模型标识和提示模板版本,否则模型或提示升级后会混用旧结果。

总结

文字游戏强化学习的工程难点,不只是选择哪种算法,而是把合法动作、语义先验、长期奖励和实验记录放在清晰边界内。环境负责定义可执行命令,模型负责候选重排,Q-learning 只从真实交互更新价值,日志负责让结果可复核。

这套结构的价值在于可替换和可比较:可以关闭模型形成基线,可以替换状态表示,也可以在不改变环境执行边界的前提下测试不同模型。只有固定任务、隔离评估并公开失败条件后,模型辅助探索是否真正改善样本效率,才是一个能够被数据回答的问题。

相关文章
|
28天前
|
人工智能 自然语言处理 API
阿里云Token Plan怎么样?支持模型与AI工具,收费标准及最新活动参考
阿里云Token Plan凭借 统一计量、多模态覆盖、工具兼容性强、数据安全可靠 等优势,已成为个人与企业拥抱AI生产力的理想入口。
|
28天前
|
人工智能 API 开发工具
阿里云百炼Token Plan完整解析:Credits计费规则、个人/团队版对比与落地实操指南
随着大模型应用快速普及,开发者与技术团队经常会遇到一系列现实痛点:项目需要交替调用不同厂商的文本、视觉、图像、视频模型,多个工具分别配置不同API密钥,账单分散难以统计;按量计费模式下,Agent长流程任务会产生大量token消耗,月度费用波动大,预算难以管控;各类AI编程IDE、Agent框架、终端编程工具各自独立对接接口,配置繁琐,切换模型成本高。
209 0
|
28天前
|
人工智能 运维 Rust
Codex终端AI编程助手完整实操|功能详解、跨平台部署与百炼Coding Plan、Token Plan接入教程
软件工程迭代速度持续加快,传统IDE普遍存在体积庞大、启动缓慢、操作流程繁琐等问题。各类图形界面AI编码插件高度依赖可视化操作环境,无法适配纯终端远程服务器开发、轻量化脚本快速迭代、服务器运维编码等真实业务场景。市面上多数终端编程工具功能较为单薄,仅支持简单代码补全,缺失项目全局理解、多步骤任务自主执行、批量工程重构、安全沙箱防护等高阶能力,同时海外模型还会遇到网络访问不稳定、成本难以管控、不符合国内合规开发体系等一系列现实阻碍。
190 0
|
28天前
|
人工智能 监控 安全
DeepSeek Harness一夜5万星,测试人的危机感一夜拉满
AI测试革命已至!DeepSeek Harness开源后12小时获5万星,能自主跑测试、分析失败、生成修复方案,正重塑测试工程师角色。它不是辅助工具,而是可追溯、可插件化的“数字员工”。执行、脚本、框架搭建层技能正被替代,但业务理解与风险决策仍是人的护城河。
|
28天前
|
Web App开发 人工智能 安全
一条TikTok测出百万生意,24个月300万粉丝,她的内容工具只有一个功能
AI时代创业新铁律:内容先行,产品随后。UC伯克利女程序员Sabrina未写一行代码前,先用TikTok系列视频验证需求,24个月获300万粉、年入百万美元。极简MVP(仅文章多平台格式转换)即变现,再用收入重构代码——用真实、透明与闭环验证,颠覆“先完美再上市”传统。
89 0
|
28天前
|
人工智能 缓存 自然语言处理
智能客服系统API对接指南:CRM与ERP数据实时同步方案
智能客服系统上线后,最大的体验断层往往发生在“客服系统”与“业务系统”的边界处。客户来电查询订单状态,坐席在客服系统里查不到ERP数据;客户投诉后,工单信息无法同步到CRM;AI机器人识别了客户意图,但业务数据调取延迟3秒以上——体验直接崩坏。本文从API对接的架构设计、鉴权方案、数据同步策略、异常处理四个维度,给出一个可落地的智能客服与CRM/ERP实时同步方案,涵盖Webhook事件推送、双向数据同步、接口延迟优化等核心工程实践。
122 0
智能客服系统API对接指南:CRM与ERP数据实时同步方案
|
19天前
|
监控 安全 API
把模型 API 适配层装进容器:从 Dockerfile 到可切换网关的工程实践
本文介绍基于FastAPI的轻量级多模型网关,通过抽象业务契约与供应商协议,实现模型热切换、超时控制、脱敏日志与健康检查,用Docker固化安全运行环境,助力AI服务稳定、合规、可维护地落地。(239字)
53 0
|
22天前
|
存储 安全 数据库
数据库备份的最后一公里:用临时实例完成可验证恢复与自动校验
本文揭示备份≠安全,强调“恢复验证”才是核心。通过PostgreSQL实战,演示如何在隔离环境自动恢复、分层校验(文件/数据库/数据/应用),并失败告警,将备份从“生成文件”升级为可验证的恢复能力。(239字)
55 0
|
28天前
|
人工智能 安全 语音技术
AI 技术在英语口语学习中的应用
AI赋能英语口语学习,突破传统跟读局限:支持高拟真角色对话、音素级发音纠错、逻辑与地道表达优化,并营造无压力练习环境。强调“输出-反馈-重说”闭环,兼顾AI训练与真人实战,全面提升口语能力。(239字)

热门文章

最新文章